import { Test } from '@nestjs/testing';
import { PlanAssignmentService } from '../src/modules/plan/plan-assignment.service';
import { PrismaService } from '../src/prisma/prisma.service';

/**
 * 批次归因走**账本**(plan_event_logs.assignment_id)。
 *
 * 🔴 起因是一个不报错的真 bug:`followup_plans.assignment_id` **会被下一次分配覆盖**。
 *   一条单退回/到期回池后被后面的批次挑走,旧批次就少一个人 ——
 *   **批次跑得越久(退回重分越多)缩得越厉害**,而且没有任何报错。
 *   实测:c02e1b80 分过 9 条,重分之后 `planned` 显示 **0**。
 *   (与 supersededAt 那个坑同构:批次跑得越有进展,数字反而越小。)
 *
 * 另一半:到期与退回**必须分开数**。主管的下一步动作是相反的 ——
 *   退回多 → 分配策略不对(派给了不该派的人);
 *   到期多 → 派多了 / 时效太紧 / 人不在岗。
 *   合成一个"回池率"两种病都看不出来。
 */

const SCOPE = {
  hostId: 'h1', tenantId: 't1', sourceUnits: [] as string[], clinicIds: [] as string[], userId: 'u1',
} as never;
const BATCH = 'c02e1b80-1111-4222-8333-444455556666';

/**
 * `$queryRaw` 的桩要**认 SQL**,⛔ 不能按调用次序分派。
 *
 * 原来写的是 `call === 1 ? 账本 : 成效分布` —— 2026-08-08 给 detail() 加了第三条原生查询
 * (通话记录明细)之后,次序整个错位,17 条测试一起红,报的还是
 * `Cannot read properties of undefined (reading 'toISOString')` 这种跟真因毫无关系的错。
 * ⚠️ 改成看 SQL 里的特征词:加查询时**只要不撞词就不用动测试**。
 */
function sqlKind(q: unknown): 'ledger' | 'outcomeDist' | 'outcomeRecords' | 'other' {
  const text = ((q as { strings?: string[] })?.strings ?? []).join(' ');
  if (text.includes('LEFT JOIN patients')) return 'outcomeRecords';
  if (text.includes('GROUP BY outcome')) return 'outcomeDist';
  if (text.includes('plan_event_logs')) return 'ledger';
  return 'other';
}

function makePrisma(opts: {
  /** 现在**还归本批**的 plan(重分走的人不在这里 —— 那正是问题所在) */
  plans?: Array<{ id: string; status: string; assigneeUserId: string | null; releaseReason: string | null }>;
  /** 账本口径的一行汇总;不传 = 模拟老批次(账本没记批次号) */
  ledger?: { planned: number; agents: number; released: number; expired: number; revoked: number } | null;
  criteria?: Record<string, unknown>;
  status?: string;
  createdAt?: Date;
  /** 本批账本事件(assign / release);空 = 老批次,走回落路径 */
  events?: Array<{ planId: string; event: string; assigneeUserId: string | null; reason: string | null }>;
}) {
  const plans = opts.plans ?? [];
  const queryRaw = jest.fn(async (q: unknown) =>
    sqlKind(q) === 'ledger' && opts.ledger
      ? [{
          assignment_id: BATCH,
          planned: BigInt(opts.ledger.planned), agents: BigInt(opts.ledger.agents),
          released: BigInt(opts.ledger.released), expired: BigInt(opts.ledger.expired),
          revoked: BigInt(opts.ledger.revoked),
        }]
      : [],
  );
  const prisma = {
    planAssignment: {
      findFirst: jest.fn(async () => ({
        id: BATCH, hostId: 'h1', tenantId: 't1', clinicId: 'c1', createdBy: 'leader-1',
        requestId: 'r1', criteria: opts.criteria ?? {}, attributes: null,
        expiresAt: new Date('2026-08-06T16:00:00Z'), status: opts.status ?? 'confirmed',
        revokedAt: null, revokedBy: null,
        createdAt: opts.createdAt ?? new Date('2026-08-03T13:33:00Z'),
        updatedAt: new Date('2026-08-03T13:33:00Z'),
      })),
      findMany: jest.fn(async () => [{
        id: BATCH, hostId: 'h1', tenantId: 't1', clinicId: 'c1', createdBy: 'leader-1',
        criteria: opts.criteria ?? {}, attributes: null,
        expiresAt: new Date('2026-08-06T16:00:00Z'), status: opts.status ?? 'confirmed',
        createdAt: opts.createdAt ?? new Date('2026-08-03T13:33:00Z'),
      }]),
    },
    followupPlan: {
      findMany: jest.fn(async () =>
        plans.map((p) => ({
          ...p, assignmentId: BATCH, patientId: `pat-${p.id}`, version: 1,
          snoozedUntil: null, assignmentExpiresAt: null,
        })),
      ),
    },
    // 「已处置」按人拆走**执行口径**(有通话结果才算);空 = 这批没回写过结果,
    // ⚠️ 那是生产常态(回写率约 11%),⛔ 别在 stub 里编几条让数字好看
    planExecution: { groupBy: jest.fn(async () => []) },
    planEventLog: {
      // ⚠️ 按 where 分岔:带 assignmentId 的是「本批全量事件」(新路径),
      //   带 planId 的是老批次回落路径 —— 后者在本 spec 里一律为空。
      findMany: jest.fn(async (args: { where: Record<string, unknown> }) =>
        args.where.assignmentId ? (opts.events ?? []) : [],
      ),
      groupBy: jest.fn(async () => []),
    },
    host: { findUnique: jest.fn(async () => ({ pullConfig: { timezone: 'Asia/Shanghai' } })) },
    $queryRaw: queryRaw,
  } as unknown as PrismaService;
  return { prisma, queryRaw };
}

async function build(prisma: PrismaService): Promise<PlanAssignmentService> {
  const mod = await Test.createTestingModule({
    providers: [PlanAssignmentService, { provide: PrismaService, useValue: prisma }],
  })
    .useMocker(() => ({}))
    .compile();
  return mod.get(PlanAssignmentService);
}

describe('批次归因 —— planned 不随重分失血', () => {
  test('⭐⭐ 人被后面的批次挑走后,planned 仍是**当时分的 9**(⛔ 不是现在剩的 2)', async () => {
    const { prisma } = makePrisma({
      // 9 个人里 7 个已被重分走 → followup_plans 只剩 2 条还挂在本批
      plans: [
        { id: 'p1', status: 'assigned', assigneeUserId: 'a', releaseReason: null },
        { id: 'p2', status: 'assigned', assigneeUserId: 'b', releaseReason: null },
      ],
      ledger: { planned: 9, agents: 2, released: 3, expired: 2, revoked: 0 },
    });
    const svc = await build(prisma);
    const d = await svc.detail(SCOPE, BATCH);
    expect(d.planned).toBe(9);
    expect(d.agents).toBe(2);
  });

  test('⭐⭐ 穷尽性:done + inHandPending + backToPool + **reassigned** === planned', async () => {
    // 不补 reassigned 这一桶,主管一对数就发现少了 7 个人,而少的那几个永远查不出去哪了
    const { prisma } = makePrisma({
      plans: [
        { id: 'p1', status: 'assigned', assigneeUserId: 'a', releaseReason: null },
        { id: 'p2', status: 'active', assigneeUserId: null, releaseReason: 'over_capacity' },
      ],
      ledger: { planned: 9, agents: 2, released: 3, expired: 2, revoked: 0 },
    });
    const svc = await build(prisma);
    const p = (await svc.detail(SCOPE, BATCH)).progress;
    expect(p.reassigned).toBe(7);
    expect(p.done + p.inHandPending + p.backToPool + p.reassigned).toBe(9);
  });

  test('⭐ 老批次(账本没记批次号)回落到 followup_plans 现算,⛔ 不显示 0', async () => {
    // 显示一个偏小的数,总好过显示 0 让主管以为这批根本没分成
    const { prisma } = makePrisma({
      plans: [
        { id: 'p1', status: 'assigned', assigneeUserId: 'a', releaseReason: null },
        { id: 'p2', status: 'assigned', assigneeUserId: 'b', releaseReason: null },
      ],
      ledger: null,
    });
    const svc = await build(prisma);
    const d = await svc.detail(SCOPE, BATCH);
    expect(d.planned).toBe(2);
    // ⚠️ 老批次的到期数没有任何可回落的源 → 0,⛔ 不许拿 backToPool 顶替
    expect(d.expired).toBe(0);
  });
});

describe('退回原因 —— 分布必须与 released 对得上', () => {
  /**
   * 🔴 实测踩到:`followup_plans.release_reason` 是**当前值**,而重分时 assign 的 SQL
   *   会把它 `= NULL` 清掉。于是一条退过的单被后面的批次挑走之后:
   *   `released` 还是 1(账本口径),`releaseReasons` 却变成 `[]` —— **两个数当场对不上**,
   *   而且不报错。主管看到"退了 1 条"却没有任何原因,以为是客服没填。
   *   ⇒ 分布也必须走账本。
   */
  test('⭐⭐ 人被重分走(列已被清空)后,原因分布仍在,且与 released 对得上', async () => {
    const { prisma } = makePrisma({
      plans: [], // 全被后面的批次挑走 → followup_plans 里一条不剩
      ledger: { planned: 9, agents: 2, released: 1, expired: 2, revoked: 0 },
      events: [
        { planId: 'p1', event: 'assign', assigneeUserId: 'a', reason: null },
        { planId: 'p1', event: 'release', assigneeUserId: null, reason: 'not_my_patient' },
      ],
    });
    const svc = await build(prisma);
    const d = await svc.detail(SCOPE, BATCH);
    expect(d.releaseReasons).toEqual([{ reason: 'not_my_patient', labelZh: '不是我的客户', n: 1 }]);
    expect(d.releaseReasons.reduce((s, x) => s + x.n, 0)).toBe(d.released);
  });

  test('⭐⭐ agentStats 各人之和 === planned(重分走的人也要留在名册里)', async () => {
    // 名册来自账本 assign 事件 —— 按当前 plans 建的话,被挑走的人从所有人名下蒸发,
    // 各人之和比 planned 少一大截,而这正是本仓早就锁着的不变量。
    const { prisma } = makePrisma({
      plans: [],
      ledger: { planned: 3, agents: 2, released: 1, expired: 0, revoked: 0 },
      events: [
        { planId: 'p1', event: 'assign', assigneeUserId: 'a', reason: null },
        { planId: 'p2', event: 'assign', assigneeUserId: 'a', reason: null },
        { planId: 'p3', event: 'assign', assigneeUserId: 'b', reason: null },
        { planId: 'p3', event: 'release', assigneeUserId: null, reason: 'over_capacity' },
      ],
    });
    const svc = await build(prisma);
    const d = await svc.detail(SCOPE, BATCH);
    expect(d.agentStats.reduce((s, x) => s + x.planned, 0)).toBe(d.planned);
    expect(d.agentStats.find((x) => x.userId === 'b')!.released).toBe(1);
  });

  test('⛔ 同一 plan 的多次 assign 不重复计数(退回→自认领→再退回会留第二条 assign)', async () => {
    const { prisma } = makePrisma({
      plans: [],
      ledger: { planned: 1, agents: 1, released: 0, expired: 0, revoked: 0 },
      events: [
        { planId: 'p1', event: 'assign', assigneeUserId: 'a', reason: null },
        { planId: 'p1', event: 'assign', assigneeUserId: 'a', reason: null },
      ],
    });
    const svc = await build(prisma);
    const d = await svc.detail(SCOPE, BATCH);
    expect(d.agentStats.find((x) => x.userId === 'a')!.planned).toBe(1);
  });
});

describe('批次归因 —— 到期与退回必须分开', () => {
  test('⭐⭐ expired 与 released 是两个数(合成一个"回池率"两种病都看不出来)', async () => {
    const { prisma } = makePrisma({
      plans: [{ id: 'p1', status: 'assigned', assigneeUserId: 'a', releaseReason: null }],
      ledger: { planned: 9, agents: 2, released: 3, expired: 2, revoked: 0 },
    });
    const svc = await build(prisma);
    const d = await svc.detail(SCOPE, BATCH);
    expect(d.released).toBe(3);
    expect(d.expired).toBe(2);
  });

  test('⭐ note 要把两者分开说出来 —— 助手是照抄这句话的', async () => {
    const { prisma } = makePrisma({
      plans: [{ id: 'p1', status: 'assigned', assigneeUserId: 'a', releaseReason: null }],
      ledger: { planned: 9, agents: 2, released: 3, expired: 2, revoked: 0 },
    });
    const svc = await build(prisma);
    const note = (await svc.detail(SCOPE, BATCH)).progress.note;
    expect(note).toContain('客服主动退回 3 条');
    expect(note).toContain('到期没人动 2 条');
    expect(note).toContain('处理率不是成功率');
  });
});

describe('通话成效 —— 完成数 / 成功数 / 不成功及原因', () => {
  /** outcomeStats 走 $queryRaw;这里按调用顺序返回:①ledger 汇总 ②每人最近一次的 outcome 分布 */
  const withOutcomes = (
    ledger: { planned: number; agents: number; released: number; expired: number; revoked: number },
    dist: Array<{ outcome: string; n: number }>,
  ) => {
    const { prisma } = makePrisma({ plans: [], ledger });
    // ⚠️ 认 SQL 不认次序(见 sqlKind 的注释:按次序分派被新增查询撞散过一次)
    (prisma as unknown as { $queryRaw: jest.Mock }).$queryRaw = jest.fn(async (q: unknown) => {
      switch (sqlKind(q)) {
        case 'ledger':
          return [{
            assignment_id: BATCH,
            planned: BigInt(ledger.planned), agents: BigInt(ledger.agents),
            released: BigInt(ledger.released), expired: BigInt(ledger.expired),
            revoked: BigInt(ledger.revoked),
          }];
        case 'outcomeDist':
          return dist.map((d) => ({ outcome: d.outcome, n: BigInt(d.n) }));
        case 'outcomeRecords':
          // 明细:按分布展开成逐条 —— 与聚合同源,这样 records.length 天然对得上四个桶
          return dist.flatMap((d, di) =>
            Array.from({ length: d.n }, (_, i) => ({
              plan_id: `p-${di}-${i}`,
              patient_name: `患者${di}-${i}`,
              operator_user_id: 'a',
              outcome: d.outcome,
              notes: i === 0 ? `纪要 ${d.outcome}` : null,
              created_at: new Date('2026-08-05T02:00:00Z'),
            })),
          );
        default:
          return [];
      }
    });
    return prisma;
  };

  test('⭐⭐ 按组汇总:成功 / 不成功 / 打了没进展,并给出不成功的原因明细', async () => {
    const svc = await build(
      withOutcomes({ planned: 20, agents: 2, released: 0, expired: 0, revoked: 0 }, [
        { outcome: 'success_appointed', n: 3 },
        { outcome: 'scheduled_next', n: 2 }, // 也算成功(约到下次是有效推进)
        { outcome: 'refused', n: 4 },
        { outcome: 'external_treatment', n: 1 },
        { outcome: 'no_answer', n: 5 },
      ]),
    );
    const o = (await svc.detail(SCOPE, BATCH)).outcomes;
    expect(o.success).toBe(5); // 3 + 2
    expect(o.failed).toBe(5); // 4 + 1
    expect(o.keep).toBe(5);
    expect(o.noOutcome).toBe(5); // 20 − 15
    // ⚠️ 原来是在 `note` 那句成品句子里找「明确拒绝 4」——note 已删(见下方那条测试),
    //   改成查 `byOutcome`:不成功的**原因明细**本来就该从结构化字段里读
    const giveUp = Object.fromEntries(
      o.byOutcome.filter((b) => b.group === 'give_up').map((b) => [b.labelZh, b.n]),
    );
    expect(giveUp).toEqual({ 明确拒绝: 4, 已在外院治疗: 1 });
    // ⭐ 四桶穷尽
    expect(o.success + o.failed + o.keep + o.noOutcome).toBe(20);
  });

  test('⭐⭐ 被后面批次挑走的人若在本批打过电话,成效仍算本批(⛔ 不许重复扣减)', async () => {
    // 踩过:noOutcome 里又减了一次 reassigned → 被压成 0,"没人做"这个最该报的信号消失。
    // 「现在归谁」与「做没做」是正交的两个维度。
    const svc = await build(
      withOutcomes({ planned: 10, agents: 1, released: 0, expired: 0, revoked: 0 }, [
        { outcome: 'success_appointed', n: 4 },
      ]),
    );
    const d = await svc.detail(SCOPE, BATCH);
    expect(d.progress.reassigned).toBe(10); // 现在一个都不归本批了
    expect(d.outcomes.success).toBe(4); // 但那 4 通电话仍算本批的成效
    expect(d.outcomes.noOutcome).toBe(6); // ⛔ 不是 0
  });

  test('⭐⭐ 「一次结果都没有」必须单独成桶,⛔ 不许并进「不成功」', async () => {
    // 它不是效果差,是**根本没做/没记** —— 混进去会把执行问题读成召回问题,
    // 而这两件事主管要改的东西完全相反。
    const svc = await build(
      withOutcomes({ planned: 10, agents: 1, released: 0, expired: 0, revoked: 0 }, [
        { outcome: 'refused', n: 1 },
      ]),
    );
    const o = (await svc.detail(SCOPE, BATCH)).outcomes;
    expect(o.failed).toBe(1);
    expect(o.noOutcome).toBe(9);
  });

  /**
   * ⚠️ 这三条原来断言的是 `outcomes.note` 那句成品句子的**措辞**。
   *   2026-08-08 该字段删掉了(它把给模型的指令混进了给人看的数,模型照抄后
   *   「不要算成功率、不要画图」直接贴进了主管的对话框),
   *   护栏改由 MCP 工具说明承担 —— 所以断言跟着落到**结构**上:
   *   措辞会改,而"哪个结果算进哪个桶"是不能变的。
   */
  test('⭐ 一条结果都没有 → 全进 noOutcome,⛔ 不许混进 failed', async () => {
    const svc = await build(
      withOutcomes({ planned: 8, agents: 1, released: 0, expired: 0, revoked: 0 }, []),
    );
    const o = (await svc.detail(SCOPE, BATCH)).outcomes;
    expect(o.noOutcome).toBe(8);
    // 🔴 「没做」⛔ 不是「做了没成」—— 混进 failed 会把执行问题读成召回问题
    expect(o.failed).toBe(0);
    expect(o.success + o.failed + o.keep + o.noOutcome).toBe(8);
  });

  test('⭐ 「约定下次回访」算 success,⛔ 不算 keep/failed', async () => {
    const svc = await build(
      withOutcomes({ planned: 5, agents: 1, released: 0, expired: 0, revoked: 0 }, [
        { outcome: 'scheduled_next', n: 2 },
      ]),
    );
    const o = (await svc.detail(SCOPE, BATCH)).outcomes;
    // 约到下次也是有效推进 —— 这正是那句被删掉的提醒背后的**事实**
    expect(o.success).toBe(2);
    expect(o.keep).toBe(0);
    expect(o.failed).toBe(0);
    expect(o.noOutcome).toBe(3);
  });

  test('⭐⭐ 通话记录明细与四个桶**对得上**:records.length === 成功+不成功+没进展', async () => {
    const svc = await build(
      withOutcomes({ planned: 20, agents: 2, released: 0, expired: 0, revoked: 0 }, [
        { outcome: 'success_appointed', n: 3 },
        { outcome: 'refused', n: 4 },
        { outcome: 'no_answer', n: 5 },
      ]),
    );
    const o = (await svc.detail(SCOPE, BATCH)).outcomes;
    // 🔴 明细和聚合必须同源同口径(T14):对不上主管就只能猜哪个是真的
    expect(o.records).toHaveLength(o.success + o.failed + o.keep);
    // ⛔ 「没有结果」的人**不许**在明细里造空行凑数
    expect(o.records).toHaveLength(20 - o.noOutcome);
    expect(o.recordsTruncated).toBe(false);
    // 纪要没写的按 null 给 —— 界面据此显示"没留纪要"(空字符串也归 null)
    expect(o.records.some((r) => r.notes === null)).toBe(true);
    expect(o.records.some((r) => typeof r.notes === 'string' && r.notes.length > 0)).toBe(true);
    // group 与四个桶同源,界面据它上色
    expect(new Set(o.records.map((r) => r.group))).toEqual(new Set(['close', 'give_up', 'keep']));
  });

  test('⛔ `outcomes` 不再返回成品句子 note —— 里面混着给模型的指令,会被原样贴进对话框', async () => {
    const svc = await build(
      withOutcomes({ planned: 20, agents: 1, released: 0, expired: 0, revoked: 0 }, [
        { outcome: 'success_appointed', n: 2 },
      ]),
    );
    expect((await svc.detail(SCOPE, BATCH)).outcomes).not.toHaveProperty('note');
  });

  test('⛔ 通话成效与退回原因是两套数,不能互相顶替', async () => {
    // releaseReasons 来自账本 release 事件(没打就还回去了);outcomes 来自 plan_executions(打了)
    const svc = await build(
      withOutcomes({ planned: 6, agents: 1, released: 2, expired: 0, revoked: 0 }, [
        { outcome: 'refused', n: 1 },
      ]),
    );
    const d = await svc.detail(SCOPE, BATCH);
    expect(d.released).toBe(2); // 退回 2 条
    expect(d.outcomes.failed).toBe(1); // 打了被拒 1 条 —— 与上面那 2 条无关
  });
});

describe('批次名 —— 没有列表页时,主管指认一批的唯一抓手', () => {
  test('⭐⭐ 含时间(到分钟)· 治疗项 · **温度** · 人数 · 客服数', async () => {
    const { prisma } = makePrisma({
      plans: [],
      ledger: { planned: 9, agents: 2, released: 0, expired: 0, revoked: 0 },
      criteria: { potentialTreatment: 'perio', temperature: 'warm' },
      createdAt: new Date('2026-08-03T13:33:00Z'), // 东八区 = 8/3 21:33
    });
    const svc = await build(prisma);
    const d = await svc.detail(SCOPE, BATCH);
    expect(d.label).toBe('8/3 21:33 · 牙周治疗 · 窗口内 · 9 人 · 2 位客服');
  });

  test('⭐ 时间按**宿主时区**,⛔ 不是服务器本地时区', async () => {
    // 主管说的"今天下午那批"是他诊所墙上的时间;服务器在别的时区时标签会指向另一天,
    // 而这个名字正是他指认批次的抓手。
    const { prisma } = makePrisma({
      plans: [],
      ledger: { planned: 1, agents: 1, released: 0, expired: 0, revoked: 0 },
      criteria: {},
      createdAt: new Date('2026-08-03T17:00:00Z'), // UTC 8/3 17:00 → 东八区已是 8/4 01:00
    });
    const svc = await build(prisma);
    expect((await svc.detail(SCOPE, BATCH)).label).toContain('8/4 01:00');
  });

  test('⭐ 老批次没存温度 → 那一段**不出现**(⛔ 不许默认填一个)', async () => {
    // 填了会让主管以为那批是按"窗口内"圈的,而他可能圈的是"黄金期"
    const { prisma } = makePrisma({
      plans: [],
      ledger: { planned: 5, agents: 1, released: 0, expired: 0, revoked: 0 },
      criteria: { potentialTreatment: 'implant' },
    });
    const svc = await build(prisma);
    const label = (await svc.detail(SCOPE, BATCH)).label;
    expect(label).toContain('种植治疗');
    expect(label).not.toMatch(/黄金期|窗口内|窗口外/);
  });

  test('已撤销的批次要在名字里标出来 —— 主管扫列表时最先要排除的就是它', async () => {
    const { prisma } = makePrisma({
      plans: [],
      ledger: { planned: 9, agents: 2, released: 0, expired: 0, revoked: 9 },
      status: 'revoked',
    });
    const svc = await build(prisma);
    expect((await svc.detail(SCOPE, BATCH)).label).toContain('已撤销');
  });
});
