import { Injectable, Logger } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import {
  AnchorMode,
  ANCHOR_MODE_META,
  PERSONA_TAG_FILTER_DIMS,
  personaTagDimId,
  visitRecencyRange,
  VISIT_RECENCY_BUCKETS,
  TEMPERATURE_ORDER,
  type AnchorModeValue,
  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);

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(),
    /// 档位口径(按诊断 / 按末诊)。⚠️ 出确认单时**必须**把同一个值带进 criteria,见 AnchorMode 注释
    anchorMode: AnchorModeValue = AnchorMode.DIAGNOSIS,
  ) {
    void now; // 判档一律用 SQL 的 NOW(),⛔ 别把 JS 的时刻掺进来(两个时钟会让边界人群漂)
    const rows = await this.prisma.$queryRaw<
      Array<{ label: string; temp: string | null; n: bigint }>
    >(
      Prisma.sql`
        WITH la AS (
          ${planLabelAnchorsSql(poolBaseSql(scope, clinicId), anchorMode)}
        )
        SELECT label,
               ${temperatureBucketCaseSql(
                 Prisma.raw('hot_until'),
                 Prisma.raw('warm_until'),
                 Prisma.raw('anchor_at'),
               )} AS temp,
               count(DISTINCT patient_id) AS n
        FROM la
        GROUP BY 1, 2
      `,
    );

    const cells = new Map<string, Record<string, number>>();
    for (const r of rows) {
      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')!;
    const unknownTotal = rows.filter((r) => r.temp == null).reduce((a, r) => a + Number(r.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,
      /// ⭐ 口径随数据一起下发 —— 前端拿它渲染列头说明,助手拿它进 criteria。
      ///   ⛔ 别让前端"自己记得刚才切的是哪版":那等于第二份真理源,刷新一次就漂。
      anchorMode,
      anchorModeZh: ANCHOR_MODE_META[anchorMode].zh,
      note:
        unknownTotal > 0
          ? `另有 ${unknownTotal} 人**算不出档位**(${
              anchorMode === AnchorMode.LAST_VISIT ? '没有末诊记录' : '召回证据里没有可用的诊断日'
            }),已单列,⛔ 没有并进任何一档 —— 并进去数字好看但那是假分布。`
          : '',
    };
  }

  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} 人**算不出温度**(召回证据里没有可用的诊断日),` +
            `所以热/温/冷三档加起来会少这些人。这是重算进度,不是数据缺失,如实说明即可。`
          : ''),
    };
  }

  /**
   * 「温度未知」的条数 —— 三档之和对不上总数时,差额在这里,**必须报出来**。
   *
   * 谁会是未知(2026-08 换源后):召回证据里**没有可用的诊断日**(锚点缺失)。
   * 换源前的语义是"画像还没算出边界",那个原因已经不存在了 —— 温度改成读时从锚点算,
   * 不再依赖画像里存好的边界,所以也**不再有"等重算"这回事**。
   * ⛔ 把它们塞进「冷」能让数字对上,但那是**假的分布**(见 temperature.ts 的第 ③ 条 / T14)。
   * ✅ 报出来,主管看到「另有 N 人温度待重算」,知道这是进度问题不是数据问题。
   *
   * 只在指定了治疗项时才有意义(温度必须挂在治疗项上);不指定 → 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 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 取值分桶 */
  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(' / ');
