import {
  EXECUTION_OUTCOME_META,
  SUPPRESS_PERMANENT_DAYS,
  BREAKER_SUPPRESS_DAYS,
  ABANDON_REASON_META,
  abandonReasonsFor,
} from '@pac/types';
import { resolveSnoozedUntil } from '../src/modules/plan/recall-suppression';
import {
  computeConfidenceFactor,
  computeFreshness,
  calcPriority,
  type PriorityInput,
} from '../src/modules/plan/engine/priority-scorer';

/**
 * 召回闭环:熔断 / 拒绝 / 回访冷静期(snoozedUntil)。
 *
 * 修复的核心 bug(GAP1):终态 plan(completed/abandoned)在重算时被当成"没有活动 plan"
 *   直接新建一版 active → "明确拒绝 / 4 次未接通熔断"第二天就复活、患者重回呼叫池。
 * 现在 execution 回写按 outcome 算 snoozedUntil,plan 引擎 upsert 在抑制期内返回 suppressed。
 */
describe('召回抑制窗 snoozedUntil 计算', () => {
  const NOW = new Date('2026-06-01T00:00:00Z');
  const daysFromNow = (d: Date | null) =>
    d == null ? null : Math.round((d.getTime() - NOW.getTime()) / 86_400_000);

  test('明确拒绝 refused → 90d 冷静期', () => {
    const r = resolveSnoozedUntil({ outcome: 'refused', breakerTripped: false, now: NOW });
    expect(daysFromNow(r)).toBe(90);
  });

  test('再考虑 declined_recent → 7d(keep 非终态,7天冷静期后自动浮现)', () => {
    const r = resolveSnoozedUntil({ outcome: 'declined_recent', breakerTripped: false, now: NOW });
    expect(daysFromNow(r)).toBe(7);
  });

  test('成功转化 success_appointed → 60d(覆盖预约→治疗→摄入延迟,避免重复召)', () => {
    const r = resolveSnoozedUntil({ outcome: 'success_appointed', breakerTripped: false, now: NOW });
    expect(daysFromNow(r)).toBe(60);
  });

  test('客服放弃 abandoned → 60d', () => {
    const r = resolveSnoozedUntil({ outcome: 'abandoned', breakerTripped: false, now: NOW });
    expect(daysFromNow(r)).toBe(60);
  });

  test('外院治疗 external_treatment → 永久(真闭环)', () => {
    const r = resolveSnoozedUntil({ outcome: 'external_treatment', breakerTripped: false, now: NOW });
    expect(daysFromNow(r)).toBe(SUPPRESS_PERMANENT_DAYS);
  });

  test('标记无效 marked_invalid → 永久', () => {
    const r = resolveSnoozedUntil({ outcome: 'marked_invalid', breakerTripped: false, now: NOW });
    expect(daysFromNow(r)).toBe(SUPPRESS_PERMANENT_DAYS);
  });

  test('熔断优先于 outcome 自身策略 → 固定 30d(联系不上,非拒绝,换天再试)', () => {
    // outcome=no_answer 本身 suppressDays=null,但熔断触发 → 30d
    const r = resolveSnoozedUntil({ outcome: 'no_answer', breakerTripped: true, now: NOW });
    expect(daysFromNow(r)).toBe(BREAKER_SUPPRESS_DAYS);
  });

  test('熔断即便发生在终态 outcome 上,也用 30d 而非 outcome 的窗', () => {
    // 防御:breaker 优先级最高
    const r = resolveSnoozedUntil({ outcome: 'refused', breakerTripped: true, now: NOW });
    expect(daysFromNow(r)).toBe(BREAKER_SUPPRESS_DAYS);
  });

  test('keep 类带 scheduledNextAt(约定回访)→ snooze 到该时间点', () => {
    const next = '2026-06-15T02:00:00Z';
    const r = resolveSnoozedUntil({
      outcome: 'scheduled_next',
      breakerTripped: false,
      scheduledNextAt: next,
      now: NOW,
    });
    expect(r?.toISOString()).toBe(new Date(next).toISOString());
  });

  test('考虑中 considering 无 scheduledNextAt → 不 snooze(null,维持现状)', () => {
    const r = resolveSnoozedUntil({ outcome: 'considering', breakerTripped: false, now: NOW });
    expect(r).toBeNull();
  });

  test('未接通 no_answer 无 scheduledNextAt → 不 snooze', () => {
    const r = resolveSnoozedUntil({ outcome: 'no_answer', breakerTripped: false, now: NOW });
    expect(r).toBeNull();
  });
});

/**
 * 优先级 v3.0 三维模型(7eb8dbe 重构后)。
 * 原 GAP2"considering 不加权":v3 scorer 不再消费 execution outcome(likelihood bonus 已删),
 * 由设计保证 —— 这里改锁 v3 的确定性因子(置信度 / 新鲜度 / 三维加权)。
 */
describe('priority-scorer v3 | 确定性因子', () => {
  test('置信度因子:直用来源置信度,夹在 [0.5, 1](防过罚)', () => {
    expect(computeConfidenceFactor(1)).toBe(1); // 医生诊断
    expect(computeConfidenceFactor(0.8)).toBe(0.8); // 建议
    expect(computeConfidenceFactor(0.2)).toBe(0.5); // 下限地板
    expect(computeConfidenceFactor(1.5)).toBe(1); // 上限
  });

  test('新鲜度:黄金窗内满权重,过窗线性衰减,2×窗见地板 0.4', () => {
    expect(computeFreshness(0, 180)).toBe(1.0);
    expect(computeFreshness(180, 180)).toBe(1.0); // 窗上界仍满
    expect(computeFreshness(270, 180)).toBeCloseTo(0.7); // 过窗一半
    expect(computeFreshness(360, 180)).toBeCloseTo(0.4); // 2×窗
    expect(computeFreshness(3600, 180)).toBeCloseTo(0.4); // 地板不再降
  });

  const baseInput: PriorityInput = {
    urgencyLevel: 'high',
    daysSince: 0,
    windowDays: 180,
    primaryCode: 'K02',
    toothCount: 1,
    rfmSegment: null,
    consultIntentMatch: false,
    lifecycleStage: null,
    hasTreatmentHistory: false,
    isReferralChampion: false,
    recentVisitGood: false,
    confidence: 1,
  };

  test('三维加权:急迫×0.4 + 价值×0.3 + 意愿×0.3(缺省 rfm=5/意向=2/信任=4)', () => {
    const r = calcPriority(baseInput);
    // urgency high=7 → 2.8;K02 价值 2 → 0.6;意愿 5*0.375+2*0.375+4*0.25=3.625 → 1.0875
    expect(r.breakdown.urgency).toBe(7);
    expect(r.breakdown.value).toBe(2);
    expect(r.breakdown.willingness).toBe(3.63);
    expect(r.breakdown.base).toBe(4.49);
    // score 与展示值同源:raw 先定到两位小数(4.4875 → 4.49),score = 该值 × 10。
    // 2026-07-29 前这里是 round(4.4875 × 10) = 45 —— 排序键被取整,比展示精度粗 10 倍,
    // 召回池档内乱序就是这么来的(见 priority-score-precision.spec.ts)。
    expect(r.breakdown.raw).toBe(4.49);
    expect(r.score).toBe(44.9);
  });

  test('种植(K08)价值按牙数分档:单颗8 / 多颗9 / 半口+10', () => {
    expect(calcPriority({ ...baseInput, primaryCode: 'K08', toothCount: 1 }).breakdown.value).toBe(8);
    expect(calcPriority({ ...baseInput, primaryCode: 'K08', toothCount: 3 }).breakdown.value).toBe(9);
    expect(calcPriority({ ...baseInput, primaryCode: 'K08', toothCount: 6 }).breakdown.value).toBe(10);
  });

  test('修复(K03)≥3颗提档:5 → 6;未映射病种价值兜底 3', () => {
    expect(calcPriority({ ...baseInput, primaryCode: 'K03', toothCount: 2 }).breakdown.value).toBe(5);
    expect(calcPriority({ ...baseInput, primaryCode: 'K03', toothCount: 3 }).breakdown.value).toBe(6);
    expect(calcPriority({ ...baseInput, primaryCode: 'K99', toothCount: 1 }).breakdown.value).toBe(3);
  });

  test('信任基础:lifecycle 底分 + 治疗史/转介/近1年履约各+1,封顶 10', () => {
    const r = calcPriority({
      ...baseInput,
      lifecycleStage: 'mature', // 9
      hasTreatmentHistory: true,
      isReferralChampion: true,
      recentVisitGood: true,
    });
    expect(r.breakdown.trustBase).toBe(10); // 9+3 → 封顶
  });

  test('老诊断 + 低置信来源:新鲜度与置信度因子相乘压低总分', () => {
    const r = calcPriority({ ...baseInput, daysSince: 360, confidence: 0.3 });
    expect(r.breakdown.freshness).toBe(0.4);
    expect(r.breakdown.confidenceFactor).toBe(0.5);
    expect(r.score).toBe(9); // round(4.4875 × 0.4 × 0.5 × 10)
  });

  test('urgency 缺失记 0;满配输入分数不破 100', () => {
    expect(calcPriority({ ...baseInput, urgencyLevel: null }).breakdown.urgency).toBe(0);
    const max = calcPriority({
      ...baseInput,
      urgencyLevel: 'urgent',
      primaryCode: 'K08',
      toothCount: 8,
      rfmSegment: 'important_value',
      consultIntentMatch: true,
      lifecycleStage: 'mature',
      hasTreatmentHistory: true,
      isReferralChampion: true,
      recentVisitGood: true,
    });
    expect(max.score).toBeLessThanOrEqual(100);
    expect(max.score).toBeGreaterThan(90);
  });
});

describe('抑制策略单一真理源 EXECUTION_OUTCOME_META.suppressDays', () => {
  test('每个 outcome 都声明了 suppressDays(number 或 null),无遗漏', () => {
    for (const [k, v] of Object.entries(EXECUTION_OUTCOME_META)) {
      expect(v).toHaveProperty('suppressDays');
      expect(v.suppressDays === null || typeof v.suppressDays === 'number').toBe(true);
      if (typeof v.suppressDays === 'number') {
        expect(v.suppressDays).toBeGreaterThan(0);
      }
      void k;
    }
  });
});

describe('放弃原因细分抑制窗(ABANDON_REASON_META.suppressDays,多选取 max)', () => {
  const NOW = new Date('2026-06-01T00:00:00Z');
  const daysFromNow = (d: Date | null) =>
    d == null ? null : Math.round((d.getTime() - NOW.getTime()) / 86_400_000);
  const snooze = (abandonReasons?: string[]) =>
    daysFromNow(
      resolveSnoozedUntil({ outcome: 'abandoned', breakerTripped: false, now: NOW, abandonReasons }),
    );

  test('没勾原因 → outcome 默认档(客服放弃 60d),行为跟改造前一致', () => {
    expect(snooze()).toBe(60);
    expect(snooze([])).toBe(60);
  });

  test('⭐ 短档原因单独勾选 → 用自己的短档,不被 outcome 的 60d 盖掉', () => {
    // 这条锁住 max 的种子不能是 outcome 默认档(否则任何比 60d 短的档都永远生效不了)。
    // 「无法联系客户」/「号码空号」是"暂时够不着",不是拒绝,短冷静期后该再试。
    expect(snooze(['unreachable'])).toBe(30);
    expect(snooze(['wrong_number'])).toBe(30);
  });

  test('⭐ 「已完成治疗」+「机会识别不准确」= 永久(2026-07 由 14d 改)', () => {
    // 原 14d 弱语义档押注「等一个 DW 同步周期 + 排除闸接手」,生产实测证伪:
    // 缺牙两例(DW 无完成记录 / 义齿在上颌而召回在下颌)排除闸都接不住,到期只会
    // 原样弹回来再挨一次投诉。「识别不准确」同理 —— 算法什么时候修好是我们的排期,
    // 不该由患者兜着。两者都改永久后,WEAK_SIGNAL_SUPPRESS_DAYS 常量已删除。
    expect(snooze(['treated'])).toBe(SUPPRESS_PERMANENT_DAYS);
    expect(snooze(['inaccurate'])).toBe(SUPPRESS_PERMANENT_DAYS);
    // 与「已在外院治疗」同档 —— 在别家做完 vs 在自己家做完,抑制强度应一致
    expect(snooze(['treated'])).toBe(snooze(['treated_elsewhere']));
  });

  test('多选取 max —— 每个勾中的原因都独立成立,取最严的', () => {
    // 勾中任一永久档就不能被短档拉回来
    expect(snooze(['inaccurate', 'competitor'])).toBe(SUPPRESS_PERMANENT_DAYS);
    expect(snooze(['unreachable', 'inaccurate'])).toBe(SUPPRESS_PERMANENT_DAYS);
    // 两个非永久档之间仍取大的
    expect(snooze(['unreachable', 'no_intent'])).toBe(60); // max(30, 60 默认)
  });

  test('没配 suppressDays 的原因 → 参与 max 时用 outcome 默认档', () => {
    expect(snooze(['no_intent'])).toBe(60);            // 关闭弹窗侧
    expect(snooze(['patient_refused'])).toBe(60);      // PAC 表单侧
    expect(snooze(['wrong_number', 'no_intent'])).toBe(60); // max(30, 60)
  });

  test('未知 key 被忽略;全是未知 → 退回 outcome 默认档', () => {
    expect(snooze(['bogus'])).toBe(60);
    expect(snooze(['bogus', 'unreachable'])).toBe(30);
  });

  test('熔断优先级仍最高', () => {
    const r = resolveSnoozedUntil({
      outcome: 'abandoned', breakerTripped: true, now: NOW, abandonReasons: ['competitor'],
    });
    expect(daysFromNow(r)).toBe(BREAKER_SUPPRESS_DAYS);
  });

  test('原因只对有固定抑制窗的 outcome 生效(keep 类不受影响)', () => {
    const r = resolveSnoozedUntil({
      outcome: 'no_answer', breakerTripped: false, now: NOW, abandonReasons: ['competitor'],
    });
    expect(r).toBeNull();
  });
});

describe('两种回访模式共用一套原因 —— PAC 侧不为「关闭」另立概念', () => {
  test('shownIn 分流:两个面板各取各的子集,也有共用的', () => {
    const form = abandonReasonsFor('form');
    const close = abandonReasonsFor('close');
    // 各自专有
    expect(form).toContain('wrong_number');
    expect(close).not.toContain('wrong_number');
    expect(close).toContain('no_intent');
    expect(form).not.toContain('no_intent');
    // ⭐ 共用(2026-08-05):客服在 PAC 表单里也要能说「这条召回判断错了 / 正在治 / 已有人跟」
    for (const k of ['inaccurate', 'in_treatment', 'already_followed']) {
      expect(form).toContain(k);
      expect(close).toContain(k);
    }
  });

  test('⭐⭐ PAC 表单**不给「其他」** —— 它是统计黑洞,会变成最省事的默认选项', () => {
    // 放弃原因分布是主管调下一批的输入,而"其他"什么都不告诉他。
    // ⚠️ 宿主关闭弹窗仍保留(那边口径不由我们定)。
    expect(abandonReasonsFor('form')).not.toContain('other');
    expect(abandonReasonsFor('close')).toContain('other');
  });

  test('⭐ 新增两个原因都带抑制窗 —— 漏了会让放弃过的单第二天原样弹回来', () => {
    expect(ABANDON_REASON_META.in_treatment.suppressDays).toBeGreaterThan(0);
    expect(ABANDON_REASON_META.already_followed.suppressDays).toBeGreaterThan(0);
  });

  test('hidden 的历史值不进任何面板', () => {
    expect(abandonReasonsFor('form')).not.toContain('not_interested');
    expect(abandonReasonsFor('close')).not.toContain('not_interested');
  });

  test('没有 closed outcome —— 关闭就是 abandoned,模型层零分叉', () => {
    expect(EXECUTION_OUTCOME_META).not.toHaveProperty('closed');
    expect(EXECUTION_OUTCOME_META.abandoned.drivesStatus).toBe('abandoned');
  });

  test('每个 AbandonReason 都在 META 里有文案 + 归属面板', () => {
    for (const [key, meta] of Object.entries(ABANDON_REASON_META)) {
      expect(typeof meta.labelZh).toBe('string');
      expect(['form', 'close', 'both']).toContain(meta.shownIn);
      void key;
    }
  });
});
