import { Test } from '@nestjs/testing';
import { PERSONA_FEATURE_SPECS, PERSONA_TAG_FILTER_DIMS, PERSONA_KEY_FEATURE_KEYS,
  personaTagDimId, PERSONA_FEATURE_META, PERSONA_FEATURE_CATEGORY_META,
  PERSONA_FEATURE_CATEGORY_ORDER, personaFeatureSortKey } from '@pac/types';
import {
  FeatureRegistry,
  FEATURE_EXTRACTOR_PROVIDERS,
} from '../src/modules/persona/features/feature.registry';

/**
 * 标签注册表防漂移。
 *
 * 由来:同一个「这个标签怎么算的」历史上有四份 —— PERSONA_FEATURE_SPECS.algorithm、
 * 前端 persona-feature-hover.tsx 的 ALGORITHMS、extractor 顶部 JSDoc、extractor 代码。
 * 前端那份靠人肉同步,已经漂了:
 *   · 权益身份 hover 只列 2 类,extractor 实际产 5 类,还承诺了数据里没有的「保司 + 日期」
 *   · 转介绍达人 hover 写「社交型 = 推外人 ≥3 人」,真实门槛是「推荐 ≥3 人且带来成交」
 * 客服照着 hover 跟患者讲,说明写错比不写更糟。
 *
 * 现在说明收进标签卡(spec.display),前端只渲染不编内容。本文件守住"注册表 ↔ 实现"的一致:
 * 光收拢不设闸,过两个月照样会漂。
 */

/** extractor 都是无依赖纯函数类 → 不用起 PersonaModule(那会拽进 Prisma / ClinicalGap) */
async function registryKeys(): Promise<string[]> {
  const mod = await Test.createTestingModule({
    providers: [FeatureRegistry, ...FEATURE_EXTRACTOR_PROVIDERS],
  }).compile();
  return mod.get(FeatureRegistry).extractors.map((e) => e.key as string);
}

describe('PERSONA_FEATURE_SPECS ↔ FeatureRegistry', () => {
  test('⭐ 每个在跑的 extractor 都必须在注册表里登记(新特征上线不许漏登记)', async () => {
    const missing = (await registryKeys()).filter((k) => !PERSONA_FEATURE_SPECS[k]);
    expect(missing).toEqual([]);
  });

  test('⭐ 注册表里不留已摘除的标签(摘 extractor 时要一并清掉,否则覆盖率日报会永远报 0%)', async () => {
    const live = new Set(await registryKeys());
    const stale = Object.keys(PERSONA_FEATURE_SPECS).filter((k) => !live.has(k));
    expect(stale).toEqual([]);
  });

  // 身份卡「关键标签」是按 key 白名单挑的。改 key 名(或摘掉 extractor)时若忘了同步这份名单,
  // 页面上不会报错,只会**静悄悄少一个 chip** —— 客服看不到禁忌/免打扰这种红线标签才是事故。
  test('⭐ 关键标签白名单里的 key 都还活着', async () => {
    const live = new Set(await registryKeys());
    const dead = PERSONA_KEY_FEATURE_KEYS.filter((k) => !live.has(k));
    expect(dead).toEqual([]);
  });
});

describe('spec.display —— 详情页说明', () => {
  test('⭐ 副标题 + 至少一条取值条目,且每条都带加粗取值', () => {
    // 结构即可读性:卡片靠「取值 → 说明」两列对照来扫。曾试过把自明的标签 rules 清空,
    // 卡片退化成三段平铺文字,没有层次也无从对照 —— 要精简就精简 body,不是砍结构。
    for (const [key, spec] of Object.entries(PERSONA_FEATURE_SPECS)) {
      expect(spec.display?.subtitle?.length ?? 0).toBeGreaterThan(0);
      expect(spec.display?.rules?.length ?? 0).toBeGreaterThan(0);
      for (const r of spec.display.rules) {
        expect(`${key} 缺 label`).toBe(r.label ? `${key} 缺 label` : '');
        expect(r.body.length).toBeGreaterThan(0);
      }
      expect(`${key}:${spec.display.subtitle}`).not.toContain('undefined');
    }
  });

  test('⭐ 说明是给客服看的,不许出现实现术语', () => {
    // 这些词一旦出现,说明写的是"程序怎么实现的"而不是"这个标签什么意思"
    const JARGON = ['extractor', 'fact', 'jsonb', '字段', '接口', '口径与召回完全一致', 'K0', 'data.'];
    const offenders: string[] = [];
    for (const [key, spec] of Object.entries(PERSONA_FEATURE_SPECS)) {
      const text = [spec.display.subtitle,
                    ...spec.display.rules.map((r) => `${r.label}${r.body}`)].join(' ');
      for (const w of JARGON) if (text.includes(w)) offenders.push(`${key}: ${w}`);
    }
    expect(offenders).toEqual([]);
  });

  test('每个标签都有中文展示名(前端 hover 标题取它)', async () => {
    for (const key of await registryKeys()) expect(PERSONA_FEATURE_META[key]).toBeDefined();
  });

  test('⭐ 展示元数据里不留已摘除 / 只在路线图上的 key —— 否则"看着 30 个标签实际只出 16 个"', () => {
    const stale = Object.keys(PERSONA_FEATURE_META).filter((k) => !PERSONA_FEATURE_SPECS[k]);
    expect(stale).toEqual([]);
  });

  test('⭐ 红色是稀缺资源:只有警示类标签能用 rose(客服要扫一眼分轻重)', () => {
    const WARNING = new Set(['urgency_level', 'special_attention', 'contraindication', 'treatment_sensitivity']);
    const wrong = Object.entries(PERSONA_FEATURE_META)
      .filter(([k, m]) => (m.tone === 'rose') !== WARNING.has(k))
      .map(([k]) => k);
    expect(wrong).toEqual([]);
  });
});

describe('PERSONA_TAG_FILTER_DIMS —— 圈人字典', () => {
  test('⭐ 可筛维度的 key 必须是在跑的标签(筛一个不产出的标签 = 永远 0 结果)', async () => {
    const live = new Set(await registryKeys());
    // source='patient' 的维度(上次到诊 / 上次医生 / 偏好医生)查的是 patient_profiles 列,
    // 不是画像特征 —— 它们 key 为空,不参与本校验。为什么不做成画像:见 schema.prisma 注释。
    const orphan = PERSONA_TAG_FILTER_DIMS.filter(
      (d) => d.source !== 'patient' && !live.has(d.key),
    ).map((d) => d.key);
    expect(orphan).toEqual([]);
  });

  test('闭集维度的取值不能为空;开集维度(dynamic)不许留残缺 options', () => {
    for (const d of PERSONA_TAG_FILTER_DIMS) {
      if (d.dynamic) {
        // 开集维度(如医生姓名)取值由接口下发,options 必须**空**——
        // 留几个样例值会让前端以为是闭集、把没列出的医生筛没了。
        expect(d.options).toHaveLength(0);
        continue;
      }
      expect(d.options.length).toBeGreaterThan(0);
      for (const o of d.options) expect(o.value.length).toBeGreaterThan(0);
    }
  });

  test('⭐ 闭集维度的每个取值都要能查回中文 —— 否则已选 chip 显示成原始 code', () => {
    // 2026-07-29 线上现象:已选标签显示成「18m_plus ×」而不是「18 个月以上 ×」。
    // 根因是消费方按 d.key 查维度,而 source='patient' 的维度 key 为空串、筛选串用的是 id,
    // 查不到维度 → 退回显示 value 本身。这里锁住"用 id 一定查得回来"。
    for (const d of PERSONA_TAG_FILTER_DIMS) {
      if (d.dynamic) continue; // 开集维度取值本身就是中文(医生姓名),无需查表
      const dimId = personaTagDimId(d);
      for (const o of d.options) {
        const found = PERSONA_TAG_FILTER_DIMS.find((x) => personaTagDimId(x) === dimId);
        expect(found?.options.find((x) => x.value === o.value)?.zh).toBeTruthy();
      }
    }
  });

  test('⭐ 维度标识必须唯一 —— 同一特征出多个可筛字段时靠它区分', () => {
    // visit_recency 一个特征出三个字段(bucket / lastDoctor / preferredDoctor),
    // 只靠 key 分不开;重复的 id 会让筛选串指向错的 dataPath,筛出错的人。
    const ids = PERSONA_TAG_FILTER_DIMS.map(personaTagDimId);
    expect(new Set(ids).size).toBe(ids.length);
  });
});

/**
 * 标签类区(业务字典 A/B/C/D)—— 2026-07-30 起是**唯一一套分类**:
 * 首屏 chip 的颜色 / 首屏顺序 / 画像详情抽屉的分组与顺序全从它出。
 * 这里守住"别再长出第二套":每个标签必须有类区、类区序必须齐全、两处排序必须同键。
 */
describe('标签类区 A/B/C/D —— 分组 / 排序 / 上色的唯一源', () => {
  test('⭐ 16 个标签每个都要有类区,且是 A/B/C/D 之一', () => {
    const bad = Object.entries(PERSONA_FEATURE_SPECS)
      .filter(([, spec]) => !['A', 'B', 'C', 'D'].includes(spec.display.category))
      .map(([k]) => k);
    expect(bad).toEqual([]);
  });

  test('⭐ 类区序覆盖全部四类,顺序是业务定的 C→B→D→A(重要的在前)', () => {
    expect(PERSONA_FEATURE_CATEGORY_ORDER).toEqual(['C', 'B', 'D', 'A']);
    expect(Object.keys(PERSONA_FEATURE_CATEGORY_META).sort()).toEqual(['A', 'B', 'C', 'D']);
  });

  test('每个类区都有中文名 + 颜色(缺了抽屉标题会空、chip 会退灰)', () => {
    for (const [c, meta] of Object.entries(PERSONA_FEATURE_CATEGORY_META)) {
      expect(meta.label.length).toBeGreaterThan(0);
      expect(meta.tone.length).toBeGreaterThan(0);
      expect(c).toMatch(/^[ABCD]$/);
    }
  });

  test('⭐ 同类区内 order 不许重复 —— 重了排序就退化成不稳定的兜底比较', () => {
    const seen = new Map<string, number[]>();
    for (const spec of Object.values(PERSONA_FEATURE_SPECS)) {
      const arr = seen.get(spec.display.category) ?? [];
      arr.push(spec.display.order);
      seen.set(spec.display.category, arr);
    }
    for (const [cat, orders] of seen) {
      expect(new Set(orders).size).toBe(orders.length); // cat 见 message
      expect(cat).toMatch(/^[ABCD]$/);
    }
  });

  test('⭐ 首屏白名单里的标签排序落在类区序内(首屏与抽屉同一个 sortKey)', () => {
    const keys = [...PERSONA_KEY_FEATURE_KEYS];
    const sorted = [...keys].sort((a, b) => {
      const [ca, oa] = personaFeatureSortKey(a);
      const [cb, ob] = personaFeatureSortKey(b);
      return ca - cb || oa - ob;
    });
    // 排完之后类区下标必须单调不减 —— 若有标签没登记类区会被推到最后,这条会挂
    const cats = sorted.map((k) => personaFeatureSortKey(k)[0]);
    for (let i = 1; i < cats.length; i++) expect(cats[i]!).toBeGreaterThanOrEqual(cats[i - 1]!);
    expect(cats[cats.length - 1]!).toBeLessThan(PERSONA_FEATURE_CATEGORY_ORDER.length);
  });
});
