import { Injectable, Logger } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import {
  PERSONA_TAG_FILTER_DIMS,
  personaTagDimId,
  visitRecencyRange,
  VISIT_RECENCY_BUCKETS,
  TEMPERATURE_ORDER,
  type PersonaTagFilterDim,
  type TemperatureValue,
} from '@pac/types';
import { PrismaService } from '../../prisma/prisma.service';
import type { TenantScopeContext } from '../../common/decorators/tenant-scope.decorator';
import { cohortWhereSql, poolBaseSql, type CohortCriteria } from './cohort-filter';
import {
  AGE_YEARS_SQL,
  labelCaseSql,
  planLabelAnchorsSql,
  temperatureBucketCaseSql,
} from './reason-temperature.sql';

/**
 * CohortAttributesService —— T9-B「调整阶段画像圈人」的取数层。**纯只读**。
 *
 * 主管说「只要商保直付的」「排掉怕疼的」时,助手得先知道**这批人里有哪些口子可切、各多少人**,
 * 才敢回一句有依据的话(T14:有理有据才能信服)。这个工具就出那张分布。
 *
 * ═══ T10:一个可扩展接口,不为每个因素开新接口 ═══════════════════════
 * 维度全集 = `PERSONA_TAG_FILTER_DIMS`(与列表页筛选面板、`list_recall_queue` 的 personaTags
 * **同一张表**)。以后要加权益身份、治疗史、家庭结构,是往那张表加一行的事,
 * 这里一个字都不用改 —— 教条五把 `getDedicatedCs` + `getPersonaFeatures` 合并成一个口,
 * 就是这个意思。
 *
 * ═══ 三条刻意的边界 ═══════════════════════════════════════════════
 * ⛔ **不 join 在岗名册、不返回 `stillActive`**(六·已定取舍:助手不校验专属客服在岗)——
 *    在岗数据只有 23.1% 与专属客服吻合,拿半准的数据挡人不如把事实给主管自己判。
 * ⛔ **不替主管做决定**:只出分布,不建议"要不要切";切不切是主管的事(T8)。
 * ⛔ **「没有这个标签」不等于「反面」**:32 人商保直付,剩下 68 人**不是自费**,
 *    是**没这条画像证据**。所以每个维度都单独返回 `noTag`,措辞也在 note 里写死 ——
 *    模型极容易把它说成「其余 68 人自费」,那是凭空造事实(T14)。
 */

/** 默认出哪些维度:面板上可见的 + 闭集的。隐藏维度(如治疗敏感「怕疼」)仍可用 keys 显式点名。 */
const DEFAULT_DIMS = PERSONA_TAG_FILTER_DIMS.filter((d) => !d.hidden && !d.dynamic);

/**
 * 「还能再筛一刀」参与的闭集维度 —— **⛔ 不要一次全给**。
 *
 * 16 个维度全摆出来,那不是引导是一张表单。这里只留主管真会据此改打法的三个,
 * 加上单独处理的消费(rfm.monetaryCents,连续量,不在这张表里)。
 *
 * ⛔ **`rfm.segment` 刻意不在**:档位口径改成末诊之后(2026-08-13),
 *   时间档与 RFM 的 R 分**完全同尺**(R 档界 730/1095 与档位边界重合)——
 *   「3 年以上」那一格里每个人非「低活跃」即「重要挽留」,是**恒等式不是发现**。
 *   R 已被格子钉死,这个维度上还能动的只有 M(消费)与 F(频次),所以只取消费。
 * ⚠️ 「折扣锚点」也不在:它的值是「最低折扣率 + 日期/项目」,是连续量、没有闭集取值。
 *   要用它得先定分桶(≥9折 / 8–9折 / <8折),那是产品判断,⛔ 不在这里替产品拍。
 */
const NARROW_DIMS = ['referral_champion', 'entitlement_status', 'acquisition_channel']
  .map((k) => PERSONA_TAG_FILTER_DIMS.find((d) => d.key === k))
  .filter((d): d is PersonaTagFilterDim => !!d);

/** 「还能再筛一刀」的取数结果 —— 纯事实,⛔ 不含任何"要不要切"的判断。 */
export interface NarrowFacts {
  /** 候选集人数(圈定的人,⛔ 不是最终发出去的 N 个) */
  size: number;
  /** 累计净消费:这批的平均(元,已取整)+ 高于它的人数;没人有 rfm 画像时为 null */
  money: { avgYuan: number; above: number } | null;
  /** 三个闭集维度各取值命中多少人(命中 0 的已剔掉) */
  dims: Array<{
    id: string;
    nameZh: string;
    options: Array<{ value: string; zh: string; count: number }>;
  }>;
}

export interface CohortAttributeOption {
  value: string;
  zh: string;
  hint?: string;
  count: number;
}

export interface CohortAttributeDim {
  id: string;
  nameZh: string;
  /** 命中各取值的患者数;多标签维度(如潜在治疗)一个人可命中多项,**合计会大于人群数** */
  options: CohortAttributeOption[];
  /** 这批人里**没有这条画像**的人数 —— ⚠️ 不是"反面",是"没证据" */
  noTag: number;
  /** true = 一个人可命中多项,合计 > cohortSize(前端/模型别拿它算百分比) */
  multi: boolean;
}

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

  constructor(private readonly prisma: PrismaService) {}

  /**
   * 初选矩阵 —— 8 潜在治疗 × 3 温度,一次查完 24 格(T6a / T6′)。
   *
   * ⚠️ **口径是「去重患者数」不是「plan 条数」**:列表页按 plan 分页,矩阵按患者去重。
   *   两者本来就不该相等(理论上一患者一条活动 plan,但那条 partial UNIQUE 实际不存在,见 R9)。
   *   → 前端 hover 必须写「N 位患者」而不是「N 条」,否则主管一对数就觉得系统在骗他。
   *
   * ⚠️ 池子条件走**共用**的 `poolBaseSql` —— 与出确认单、看属性分布同一份,
   *   点进格子拿到的人必须与格子里的数一致(T6a 同源保证)。
   *
   * ⭐ `unknown` 一列必须存在:上线到全量重算跑完之间,老画像没有窗口边界。
   *   ⛔ 把它们并进「冷」能让行合计好看,但那是假分布(见 temperature.ts 第 ③ 条 / T14)。
   */
  async matrix(scope: TenantScopeContext, clinicId: string, now: Date = new Date()) {
    void now; // 判档一律用 SQL 的 NOW(),⛔ 别把 JS 的时刻掺进来(两个时钟会让边界人群漂)
    /**
     * 🔴 `GROUPING SETS` 多出的那一组 `(temp)` 是**跨标签去重**的那一行(`label` 为 NULL)——
     *   「另有 N 人算不出档位」必须取它,⛔ 不能把各行的 unknown 相加。
     *   2026-08-16 实测(测试服上海世纪公园):竖着加得 50,去重是 42 ——
     *   一个患者有几个治疗项就被数几次,而那句话写的是「**人**」。
     *   本方法开头那条口径(「矩阵按患者去重」「⛔ 别让主管一对数就觉得系统在骗他」)
     *   讲的正是这件事,偏偏这行汇总自己踩了。
     * ⚠️ 必须与各格**同一次查询**算出来:分两次查就是两个 `NOW()`,
     *   边界上的人会在两个时刻落进不同档,差额又对不上(同上面 `void now` 那条)。
     * ⚠️ `la` 里 `label` 恒非 NULL(WHERE 已滤),所以 `label IS NULL` 唯一地标出汇总行。
     */
    const rows = await this.prisma.$queryRaw<
      Array<{ label: string | null; temp: string | null; n: bigint }>
    >(
      Prisma.sql`
        WITH la AS (
          ${planLabelAnchorsSql(poolBaseSql(scope, clinicId))}
        ),
        b AS (
          SELECT patient_id, label,
                 ${temperatureBucketCaseSql(
                   Prisma.raw('hot_until'),
                   Prisma.raw('warm_until'),
                   Prisma.raw('anchor_at'),
                 )} AS temp
          FROM la
        )
        -- label 为 NULL 的那一行 = 跨标签去重的汇总(见方法内注释),⛔ 别改成各行相加
        SELECT label, temp, count(DISTINCT patient_id) AS n
        FROM b
        GROUP BY GROUPING SETS ((label, temp), (temp))
      `,
    );

    const cells = new Map<string, Record<string, number>>();
    for (const r of rows) {
      if (r.label == null) continue; // 汇总行(GROUPING SETS 那一组),下面单独取
      const row = cells.get(r.label) ?? {};
      row[r.temp ?? 'unknown'] = Number(r.n);
      cells.set(r.label, row);
    }
    // 行序照 PERSONA_TAG_FILTER_DIMS 的声明序(= 业务上「客服最先问什么」的排序),
    // ⛔ 别按人数排 —— 那会让矩阵每天换一个样子,主管的肌肉记忆全废。
    const labelDim = PERSONA_TAG_FILTER_DIMS.find((d) => d.key === 'potential_treatment')!;
    /// 跨标签**去重**的人数 —— ⛔ 别改回 `rows.filter(...).reduce(相加)`(见上方 SQL 里那段)
    const unknownTotal = Number(rows.find((r) => r.label == null && r.temp == null)?.n ?? 0);

    return {
      clinicId,
      rows: labelDim.options.map((o) => {
        const c = cells.get(o.value) ?? {};
        // ⚠️ counts 走 map 不平铺 —— 档位会变(3→6),平铺每加一档就要改一圈类型和前端
        const counts = Object.fromEntries(TEMPERATURE_ORDER.map((t) => [t, c[t] ?? 0])) as Record<
          TemperatureValue,
          number
        >;
        const unknown = c.unknown ?? 0;
        return {
          key: o.value,
          zh: o.zh,
          hint: o.hint,
          counts,
          unknown,
          // ⚠️ total 含 unknown:它就是「点这一行能拿到多少人」,少算了主管会以为丢了人
          total: TEMPERATURE_ORDER.reduce((a, t) => a + counts[t], 0) + unknown,
        };
      }),
      unknownTotal,
      note:
        unknownTotal > 0
          ? `另有 ${unknownTotal} 人**算不出档位**(没有末诊记录),已单列,` +
            '⛔ 没有并进任何一档 —— 并进去数字好看但那是假分布。' +
            // ⚠️ 这半句必须留着:这个数是**去重人数**,而「算不出」那一列是按治疗项分行的,
            //   一个人有几个治疗项就在几行里各出现一次 ⇒ 竖着加必然大于它。
            //   不写清楚,主管一相加就发现对不上,而那正是这行汇总上一版真的算错过的地方。
            '(同一个人有几个治疗项就会在几行里各出现一次,所以那一列竖着加会大于这个数。)'
          : '',
    };
  }

  async describe(
    scope: TenantScopeContext,
    criteria: CohortCriteria,
    opts: { keys?: string[]; now?: Date } = {},
  ) {
    const now = opts.now ?? new Date();

    // ① 先把人群定下来(与出确认单**同一份** SQL 片段,见 cohort-filter 的「为什么必须共用」)
    const rows = await this.prisma.$queryRaw<Array<{ patientId: string }>>(
      Prisma.sql`
        SELECT DISTINCT fp.patient_id AS "patientId"
        FROM followup_plans fp
        JOIN patients p ON p.id = fp.patient_id
        WHERE ${cohortWhereSql(scope, criteria, now)}
      `,
    );
    const patientIds = rows.map((r) => r.patientId);
    const cohortSize = patientIds.length;
    // ⭐ 与 assignment-proposal 那行**成对**存在:一个记"看分布时按什么条件、多少人",
    //   一个记"出确认单时按什么条件、多少人"。两个数对不上时,这两行是唯一的证据。
    //   ⛔ 别删其中任何一行 —— 只留一半就又变成"只能猜是哪边错了"。
    this.logger.log(`分布:条件=${JSON.stringify(criteria)} 人数=${cohortSize}`);

    const dims = resolveDims(opts.keys);
    if (cohortSize === 0) {
      return {
        criteria,
        cohortSize: 0,
        dimensions: [] as CohortAttributeDim[],
        dedicatedCs: { withDedicated: 0, none: 0 },
        temperatureUnknown: null as number | null,
        note: '当前条件下这批人是空的 —— 先放宽条件(去掉温度或画像标签),再看分布。',
      };
    }

    const [personaDims, visitDim, dedicated, temperatureUnknown] = await Promise.all([
      this.personaBreakdown(patientIds, dims),
      this.visitRecencyBreakdown(patientIds, dims, now),
      this.dedicatedCsCoverage(patientIds),
      this.temperatureUnknownCount(scope, criteria, now),
    ]);

    // 保持 PERSONA_TAG_FILTER_DIMS 的声明顺序(Tier1 治疗 → Tier2 行为 → Tier3 属性),
    // 那个顺序本身就是「客服最先问什么」的业务排序,别在这里重排。
    const byId = new Map([...personaDims, ...visitDim].map((d) => [d.id, d]));
    const dimensions = dims.map((d) => byId.get(personaTagDimId(d))).filter((d): d is CohortAttributeDim => !!d);

    return {
      criteria,
      cohortSize,
      dimensions,
      dedicatedCs: dedicated,
      temperatureUnknown,
      // ⭐ 成品句子,助手照抄(T14 双保险的「工具返回值」那一半)
      note:
        `这批共 ${cohortSize} 人。每个维度的 noTag 是**没有这条画像证据**的人数,` +
        `⛔ 不要说成反面(「没有商保标签」≠「自费」)。` +
        `标了 multi 的维度一个人可命中多项,合计会大于 ${cohortSize},别拿它算百分比。` +
        `专属客服覆盖只报有/无,**不判在岗** —— 谁在休假、谁离职了,以主管说的为准。` +
        (temperatureUnknown
          ? `⚠️ 另有 ${temperatureUnknown} 人**算不出档位**(没有末诊记录),` +
            `所以各档加起来会少这些人。如实说明即可,⛔ 别并进某一档。`
          : ''),
    };
  }

  /**
   * 「档位未知」的人数 —— 六档之和对不上总数时,差额在这里,**必须报出来**。
   *
   * 🔴 判据必须与 `labelTemperatureExistsSql` 落不进任何一档的那批人**逐字等价**,
   *   否则主管拿六档相加去对总数,差额就是个对不上的数(T14 口径对数)。两种情况:
   *     ① **没有末诊记录** → 锚点为 NULL → 判档 CASE 出 NULL(2026-08-13 锚点改末诊后的主因);
   *     ② 该治疗项下**没有一条带日期的在跑证据** → 连行都进不来。
   *   两者的并集 = 不落在六档里的全部人。⛔ 别只查其中一个(改锚点那次就差点只留 ②)。
   * ⛔ 把他们塞进「冷」能让数字对上,但那是**假的分布**(见 temperature.ts 第 ③ 条 / T14)。
   *
   * 只在指定了治疗项时才有意义(档位必须挂在治疗项上);不指定 → null,不做无谓查询。
   */
  private async temperatureUnknownCount(
    scope: TenantScopeContext,
    criteria: CohortCriteria,
    now: Date,
  ): Promise<number | null> {
    if (!criteria.potentialTreatment) return null;
    // 去掉温度条件,只问「这个治疗项下,有几个人算不出档」
    const { temperature: _drop, ...noTemp } = criteria;
    const rows = await this.prisma.$queryRaw<Array<{ n: bigint }>>(
      Prisma.sql`
        SELECT count(DISTINCT fp.patient_id) AS n
        FROM followup_plans fp
        JOIN patients p ON p.id = fp.patient_id
        WHERE ${cohortWhereSql(scope, noTemp, now)}
          AND (
            NOT EXISTS (
              SELECT 1 FROM patient_profiles pp
              WHERE pp.patient_id = fp.patient_id AND pp.last_visit_at IS NOT NULL
            )
            OR NOT EXISTS (
              SELECT 1
              FROM plan_reasons pr
              CROSS JOIN LATERAL jsonb_array_elements_text(pr.evidence->'factIds') fid
              JOIN patient_facts f ON f.id = fid::uuid AND f.status = 'active'
              WHERE pr.plan_id = fp.id
                AND ${labelCaseSql('f', AGE_YEARS_SQL)} = ${criteria.potentialTreatment}
                AND COALESCE(f.occurred_at, f.planned_for) IS NOT NULL
            )
          )
      `,
    );
    return Number(rows[0]?.n ?? 0);
  }

  /** persona 特征维度:一次查完所有点名的 key,在内存里按 dataPath 取值分桶 */
  /**
   * 「还能再筛一刀」要的四样,**一次查完**。
   *
   * ⭐ 2026-08-13 换掉了原来的「画像亮点」:那套要比两份分布(这一格 vs 同治疗项池子),
   *   靠倍数/百分点判断"值不值得注意"。删掉的理由不是它算错了,是**它在替主管判断**——
   *   而他要的只是"还能怎么切"。⇒ 不比基线、不设倍数闸,直接把几刀摆出来。
   *   顺带省掉每次出确认单的那次**全治疗项池子扫描**。
   *
   * ⚠️ 比的是**候选集**(圈定的人),⛔ 不是最终发出去的那 N 个 ——
   *   主管要在"这批人"上再切,那个"这批人"就是候选集。
   * 🔴 消费的平均值必须**取整到元之后再数人**:按钮上写「高于 ¥720 的 312 人」,
   *   而真去筛的时候用的是取整后的 720 —— 两边不同源就会出现"点了之后不是 312 人",
   *   且不报错(2026-08-13 设计时就按死,⛔ 别改成先数人后取整)。
   */
  async narrowFacts(
    scope: TenantScopeContext,
    criteria: CohortCriteria,
    now: Date = new Date(),
  ): Promise<NarrowFacts> {
    const rows = await this.prisma.$queryRaw<Array<{ patientId: string }>>(
      Prisma.sql`
        SELECT DISTINCT fp.patient_id AS "patientId"
        FROM followup_plans fp
        JOIN patients p ON p.id = fp.patient_id
        WHERE ${cohortWhereSql(scope, criteria, now)}
      `,
    );
    const patientIds = rows.map((r) => r.patientId);
    if (patientIds.length === 0) return { size: 0, money: null, dims: [] };

    const feats = await this.prisma.$queryRaw<Array<{ key: string; data: unknown }>>(
      Prisma.sql`
        SELECT pf.key AS "key", pf.data AS "data"
        FROM personas pe
        JOIN persona_features pf ON pf.persona_id = pe.id
        WHERE pe.patient_id = ANY(${patientIds}::uuid[])
          AND pe.superseded_at IS NULL
          AND pf.key = ANY(${[...NARROW_DIMS.map((d) => d.key), 'rfm']}::text[])
      `,
    );

    // ── 消费:先算平均、取整到元,再拿取整后的门槛数人 ──
    const cents: number[] = [];
    for (const r of feats) {
      if (r.key !== 'rfm') continue;
      const v = (r.data as Record<string, unknown> | null)?.monetaryCents;
      if (typeof v === 'number') cents.push(v);
    }
    let money: NarrowFacts['money'] = null;
    if (cents.length > 0) {
      const avgYuan = Math.round(cents.reduce((a, b) => a + b, 0) / cents.length / 100);
      money = { avgYuan, above: cents.filter((c) => c > avgYuan * 100).length };
    }

    // ── 三个闭集维度:各取值命中多少人 ──
    const dims: NarrowFacts['dims'] = [];
    for (const dim of NARROW_DIMS) {
      const counts = new Map<string, number>();
      for (const r of feats) {
        if (r.key !== dim.key) continue;
        const raw = (r.data as Record<string, unknown> | null)?.[dim.dataPath];
        const values = dim.isArray ? (Array.isArray(raw) ? raw : []) : raw != null ? [raw] : [];
        for (const v of values) if (typeof v === 'string') counts.set(v, (counts.get(v) ?? 0) + 1);
      }
      // ⚠️ 只认闭集里声明过的取值 —— 库里冒出来的野值不往主管面前放(与 personaBreakdown 同规矩)
      const options = dim.options
        .map((o) => ({ value: o.value, zh: o.zh, count: counts.get(o.value) ?? 0 }))
        .filter((o) => o.count > 0);
      if (options.length > 0) dims.push({ id: personaTagDimId(dim), nameZh: dim.nameZh, options });
    }
    return { size: patientIds.length, money, dims };
  }

  private async personaBreakdown(
    patientIds: string[],
    dims: PersonaTagFilterDim[],
  ): Promise<CohortAttributeDim[]> {
    const target = dims.filter((d) => d.source !== 'patient');
    if (target.length === 0) return [];
    const keys = [...new Set(target.map((d) => d.key))];

    const rows = await this.prisma.$queryRaw<Array<{ patientId: string; key: string; data: unknown }>>(
      Prisma.sql`
        SELECT pe.patient_id AS "patientId", pf.key AS "key", pf.data AS "data"
        FROM personas pe
        JOIN persona_features pf ON pf.persona_id = pe.id
        WHERE pe.patient_id = ANY(${patientIds}::uuid[])
          AND pe.superseded_at IS NULL
          AND pf.key = ANY(${keys}::text[])
      `,
    );

    const out: CohortAttributeDim[] = [];
    for (const dim of target) {
      const counts = new Map<string, Set<string>>();
      const tagged = new Set<string>();
      for (const r of rows) {
        if (r.key !== dim.key) continue;
        const raw = (r.data as Record<string, unknown> | null)?.[dim.dataPath];
        const values = dim.isArray ? (Array.isArray(raw) ? raw : []) : raw != null ? [raw] : [];
        for (const v of values) {
          if (typeof v !== 'string') continue;
          tagged.add(r.patientId);
          const set = counts.get(v) ?? new Set<string>();
          set.add(r.patientId);
          counts.set(v, set);
        }
      }
      out.push({
        id: personaTagDimId(dim),
        nameZh: dim.nameZh,
        // 只出**闭集里声明过**的取值,顺序照声明序 —— 库里冒出来的野值不往主管面前放
        // (那是数据问题,该在画像那层修,不是在这里展示)。
        options: dim.options
          .map((o) => ({ value: o.value, zh: o.zh, ...(o.hint ? { hint: o.hint } : {}), count: counts.get(o.value)?.size ?? 0 }))
          .filter((o) => o.count > 0),
        noTag: patientIds.length - tagged.size,
        multi: !!dim.isArray,
      });
    }
    return out;
  }

  /**
   * 「上次到诊」分档 —— 存的是日期,区间按 now 现算(与 `visitRecencyRange` 同一个函数,
   * 也与温度存边界时刻是同一条道理:存死分档会过期)。
   */
  private async visitRecencyBreakdown(
    patientIds: string[],
    dims: PersonaTagFilterDim[],
    now: Date,
  ): Promise<CohortAttributeDim[]> {
    const dim = dims.find((d) => d.patientField === 'lastVisitAt');
    if (!dim) return [];
    const rows = await this.prisma.$queryRaw<Array<{ patientId: string; lastVisitAt: Date | null }>>(
      Prisma.sql`
        SELECT pp.patient_id AS "patientId", pp.last_visit_at AS "lastVisitAt"
        FROM patient_profiles pp
        WHERE pp.patient_id = ANY(${patientIds}::uuid[]) AND pp.last_visit_at IS NOT NULL
      `,
    );
    const counts = new Map<string, number>();
    for (const r of rows) {
      if (!r.lastVisitAt) continue;
      const t = r.lastVisitAt.getTime();
      for (const b of VISIT_RECENCY_BUCKETS) {
        const range = visitRecencyRange(b.value, now);
        if (!range) continue;
        if ((range.gte ? t >= range.gte.getTime() : true) && (range.lt ? t < range.lt.getTime() : true)) {
          counts.set(b.value, (counts.get(b.value) ?? 0) + 1);
          break;
        }
      }
    }
    return [
      {
        id: personaTagDimId(dim),
        nameZh: dim.nameZh,
        options: dim.options
          .map((o) => ({ value: o.value, zh: o.zh, ...(o.hint ? { hint: o.hint } : {}), count: counts.get(o.value) ?? 0 }))
          .filter((o) => o.count > 0),
        noTag: patientIds.length - rows.length, // 从没到过诊(只约过/只咨询过)
        multi: false,
      },
    ];
  }

  /**
   * 专属客服覆盖 —— 教条五要合并的 `getDedicatedCs` 就是这一半。
   * ⛔ 只报有/无,**不 join 名册、不判在岗**(六·已定取舍)。
   */
  private async dedicatedCsCoverage(patientIds: string[]) {
    const rows = await this.prisma.patient.findMany({
      where: { id: { in: patientIds } },
      select: { id: true, preferences: true },
    });
    let withDedicated = 0;
    for (const r of rows) {
      const id = (r.preferences as { dedicatedCs?: { id?: string } } | null)?.dedicatedCs?.id;
      if (typeof id === 'string' && id) withDedicated++;
    }
    return { withDedicated, none: patientIds.length - withDedicated };
  }
}

/**
 * 点名的维度 → 维度对象。⚠️ 允许点名 `hidden` 的维度:
 * 教条 T9-B 举的例子「排掉怕疼的」正好落在 `treatment_sensitivity` 上,而它是 hidden ——
 * hidden 的语义是「前端面板不展示」,不是「不能用」(见 persona-tag-filters 的 hidden 注释)。
 * ⛔ 但**默认不给**:一次把 16 个维度全推给模型,它会挑一个跟主管无关的开始发挥。
 */
function resolveDims(keys?: string[]): PersonaTagFilterDim[] {
  if (!keys?.length) return DEFAULT_DIMS;
  const wanted = new Set(keys);
  const picked = PERSONA_TAG_FILTER_DIMS.filter((d) => wanted.has(personaTagDimId(d)) && !d.dynamic);
  return picked.length ? picked : DEFAULT_DIMS;
}

/** 工具描述里要列给模型看的维度清单(含隐藏维度)——「怕疼」这类口语得能映射到 id 上 */
export const COHORT_DIM_CATALOG = PERSONA_TAG_FILTER_DIMS.filter((d) => !d.dynamic)
  .map((d) => `${personaTagDimId(d)}(${d.nameZh})`)
  .join(' / ');
