import { Prisma } from '@prisma/client';
import {
  COLD_BUCKET_YEARS,
  COLD_TEMPERATURES,
  HOT_BUCKET_DAYS,
  POTENTIAL_LABEL_RULES,
  Temperature,
  WARM_BUCKET_DAYS,
  type TemperatureValue,
} from '@pac/types';

/**
 * 初选两根轴的**取数口径** —— 从召回单自己的证据算,不经过画像。
 *
 * ═══ 为什么换源(2026-08)═════════════════════════════════════════════
 * 原来 X 轴读画像 `potential_treatment.types`、Y 轴读画像里存好的 `hotUntil/warmUntil`。
 * 问题:画像回答的是「**这个人有哪些潜在治疗**」,而矩阵是**召回池的视图**,
 * 该回答「**引擎为什么要召回他**」。两者不是一回事 ——
 *   实测:161 个「患者×标签」格位在画像里有、但召回单根本没为这个标签立 reason。
 *   主管点「补牤」那一格捞到这批人,客服打开一看 plan 讲的是缺牙 —— 格子和人对不上。
 *
 * ✅ 换成:两根轴都从 `plan_reasons.evidence.factIds → patient_facts` 取。
 *   实测 100% 的在跑单都能追到带锚点和诊断码的事实,不存在取不到的情况。
 *
 * ═══ 顺带解决的 ═══════════════════════════════════════════════════
 * ⭐ **改窗口配置不再需要重算画像**。边界从锚点**读时**算,`DiagnosisTreatmentMap`
 *   一改立刻生效。原来边界烤在 44 万行 JSON 里,调一次窗口要跑 4.9 小时全量重算,
 *   而且不重算**不会报错**,只是全按旧窗口判。
 *
 * ⚠️ 本文件的 SQL 全部由 `@pac/types` 的常量**生成**,⛔ 一个数字都不许手抄:
 *   `POTENTIAL_LABEL_RULES`(码→标签,含年龄闸/关键词)、`DiagnosisTreatmentMap`(窗口天数)、
 *   `COLD_BUCKET_YEARS`(冷端年数)。手抄一份 = 两份真理源,分叉了也不报错。
 */

/** 患者周岁 —— 与 `ageYearsAt` 同语义(Postgres 的 age() 是生日感知的完整年数) */
export const AGE_YEARS_SQL = Prisma.sql`date_part('year', age(NOW(), p.birth_date))::int`;
/** 同上,给**外层别名是 `p`** 的关联片段用(两处别名一致,留两个常量只为可读) */
export const AGE_YEARS_SQL_P = AGE_YEARS_SQL;

/**
 * 事实 → 业务标签的 CASE,**从 POTENTIAL_LABEL_RULES 生成**。
 *
 * @param factAlias 事实表别名(需有 content jsonb)
 * @param ageSql    患者周岁表达式
 *
 * ⚠️ 规则表是**顺序敏感**的(K03 含关键词→拔牙 必须在 K03→修复 之前),
 *   CASE 的 WHEN 天然按顺序求值,与 TS 侧 `classifyCodeToLabel` 的 for-return 一致。
 * ⚠️ 年龄为 NULL 时,带年龄条件的 WHEN 求值为 NULL(非真)→ 不命中 → 落 NULL。
 *   这与 TS 侧「无生日一律不命中」一致,⛔ 别加 COALESCE 兜底。
 */
export function labelCaseSql(factAlias: string, ageSql: Prisma.Sql): Prisma.Sql {
  const code = Prisma.raw(`${factAlias}.content->>'code'`);
  const name = Prisma.raw(`COALESCE(${factAlias}.content->>'name_zh', '')`);
  const whens = POTENTIAL_LABEL_RULES.map((r) => {
    const conds: Prisma.Sql[] = [Prisma.sql`${code} = ${r.code}`];
    if (r.ageGt !== undefined) conds.push(Prisma.sql`${ageSql} > ${r.ageGt}`);
    if (r.ageMin !== undefined) conds.push(Prisma.sql`${ageSql} >= ${r.ageMin}`);
    if (r.ageMax !== undefined) conds.push(Prisma.sql`${ageSql} <= ${r.ageMax}`);
    if (r.nameAny?.length) {
      // 用 position() 逐词判包含 —— ⛔ 别用正则:关键词是业务配置,可能含正则元字符
      const anyOf = r.nameAny.map((k) => Prisma.sql`position(${k} in ${name}) > 0`);
      conds.push(Prisma.sql`(${Prisma.join(anyOf, ' OR ')})`);
    }
    return Prisma.sql`WHEN ${Prisma.join(conds, ' AND ')} THEN ${r.label}`;
  });
  return Prisma.sql`CASE ${Prisma.join(whens, ' ')} ELSE NULL END`;
}

/**
 * 前两档的边界表达式(锚点 + 固定天数)。
 *
 * 🔴 2026-08-11:天数从「该码自己的 `urgencyDayThreshold` / `windowDays`」改成**固定 90/180**
 *   (口径与理由见 `@pac/types` 的 temperature.ts 文件头)。原来这里有一张
 *   `windowValuesSql()` 生成的 `(code, urg, wnd)` VALUES 表并 JOIN 到事实的诊断码上,
 *   现在整张表连同那次 JOIN 一起删了 ——
 *   ⚠️ 删 JOIN **不改变行集**:它连的是 `POTENTIAL_LABEL_RULES` 那 8 个码,
 *     而这两个函数的 WHERE 里 `labelCaseSql(...)` 只认同一批码(K00/K09 落 NULL 已被排掉)。
 *     (`tests/potential-label-rules.spec.ts` 锁着这层等价。)
 * ⚠️ 仍然是**参数注入**,⛔ 不许把 90/180 拼进 SQL 文本 —— 常量在 `@pac/types`,
 *   这里只引用;`make_interval(days => $n)` 是为了避开 `int || text` 的隐式转换。
 * 🔴 `::int` **不能省**:Prisma 把 JS number 绑成 `double precision`,而
 *   `make_interval(days => double precision)` **这个函数不存在** → 整个 /plans/matrix 报 500
 *   (2026-08-11 实测,矩阵整片打不开)。⛔ 别顺手删掉这个 cast。
 */
/**
 * 锚点 = **患者末诊**(`lvp.last_visit_at`),只此一版。
 *
 * 🔴 2026-08-13 删掉了「按诊断」那版(`anc.at` 当锚点)和整个 `AnchorMode` 开关 ——
 *   理由(医生复诊不重复写诊断,中位差 490 天)见 `@pac/types` 的 temperature.ts 文件头。
 *   ⛔ 别加回来,也⛔别在这里留"可切换"的口子:两版并存时圈人侧与矩阵侧一旦不同步,
 *   就是主管看到 87 人、确认单捞出另一批,而且全程不报错(T14)。
 *
 * ⚠️ `last_visit_at` 取 `patient_profiles`(本地实测 5,825 人 100% 有值、100% 与
 *   encounter/emr 事实的最大 occurred_at 同日),⛔ 别为它再去 join 一遍 patient_facts。
 * ⚠️ 末诊为 NULL → 三个值全 NULL → 落 `unknown` 档(与"边界缺失"同路),
 *   ⛔ 不许默认塞进某一档。
 * ⚠️ 末诊是**患者级**的 ⇒ 同一 GROUP 内每行值都一样,`max()` 只是为了配合 GROUP BY,
 *   ⛔ 别读成"多条证据取最热"(那是诊断锚点时代的语义)。
 */
const ANCHOR_SQL = Prisma.sql`lvp.last_visit_at`;
const BOUNDS = {
  hot: Prisma.sql`max(${ANCHOR_SQL} + make_interval(days => ${HOT_BUCKET_DAYS}::int))`,
  warm: Prisma.sql`max(${ANCHOR_SQL} + make_interval(days => ${WARM_BUCKET_DAYS}::int))`,
  anchor: Prisma.sql`max(${ANCHOR_SQL})`,
};
const LAST_VISIT_JOIN = Prisma.sql`LEFT JOIN patient_profiles lvp ON lvp.patient_id = fp.patient_id`;

/**
 * 六档判定的 CASE。
 *
 * ⭐ **一个锚点走到底**:`anchor` = 患者末诊。
 *   前两档按固定 90/180 天、后四档按绝对年数,量的都是「距同一个锚点多久」。
 * ⚠️ 冷端用 `anchor + interval 'N years'` 而不是 `N*365 天` —— 与 TS 侧 `setFullYear` 同语义,
 *   365 天算法在闰年会漂,两边就对不上数。
 * ⚠️ 锚点/边界为 NULL → 落 NULL(调用方显式呈现"未知"),⛔ 不许默认塞进某个冷档。
 */
export function temperatureBucketCaseSql(
  hotUntil: Prisma.Sql,
  warmUntil: Prisma.Sql,
  anchor: Prisma.Sql,
): Prisma.Sql {
  const colds = COLD_TEMPERATURES.filter((t) => COLD_BUCKET_YEARS[t] != null).map(
    (t) =>
      Prisma.sql`WHEN NOW() <= ${anchor} + ${Prisma.raw(`interval '${COLD_BUCKET_YEARS[t]} years'`)} THEN ${t}`,
  );
  return Prisma.sql`
    CASE
      WHEN ${hotUntil} IS NULL OR ${anchor} IS NULL THEN NULL
      WHEN NOW() <= ${hotUntil}  THEN ${Temperature.HOT}
      WHEN NOW() <= ${warmUntil} THEN ${Temperature.WARM}
      ${Prisma.join(colds, ' ')}
      ELSE ${Temperature.COLD_OVER}
    END`;
}

/**
 * 每个 (plan, 标签) 一行:两个边界 + 锚点。**独立查询**,给矩阵用。
 *
 * ⚠️ 别名固定 `fp`(followup_plans)/ `p`(patients)—— 因为 `planFilter` 传进来的是
 *   `poolBaseSql`,它引用的就是这两个别名。改名就得同步改那边,⛔ 别只改一处。
 * ⚠️ 关联子查询场景**不要用本函数**(内部的 `fp` 会遮蔽外层同名别名,
 *   关联条件变成恒真且 SQL 不报错)—— 那种场景用下方的 `labelExistsSql` / `labelTemperatureExistsSql`。
 *
 * ⚠️ `f.status = 'active'`:治完的诊断是 `fulfilled` 不是删除。拿它当证据 =
 *   对着一个已经做完的诊断说"您还没做"。当前数据 5,034 条证据全是 active,
 *   这条过滤是**不变量守卫** —— 治疗落库后引擎还没重算的窗口期里,它就是唯一防线。
 * 🔴 `anc.at IS NOT NULL` **必须留着**,即便锚点已经换成末诊、不再用它定档:
 *   它是「这条证据有日期」的守卫,决定**哪些 plan 进得来**。删掉行集就变了 ——
 *   而多出来的那些人不会报错,只会悄悄出现在格子里。
 */
export function planLabelAnchorsSql(planFilter: Prisma.Sql): Prisma.Sql {
  return Prisma.sql`
    SELECT fp.id AS plan_id, fp.patient_id, lab.lbl AS label,
           ${BOUNDS.hot}    AS hot_until,
           ${BOUNDS.warm}   AS warm_until,
           ${BOUNDS.anchor} AS anchor_at
    FROM followup_plans fp
    -- 🔴 p 必须留着 —— poolBaseSql 在 scope.sourceUnits 非空时会拼
    --    AND p.source_unit IN (...)（多品牌隔离）。2026-08-17 线上 500 就是删了它：
    --    missing FROM-clause entry for table "p"。
    -- ⚠️ 本地基准脚本当时硬编了 sourceUnits=[]，永远走不到那个分支 —— 测了个假场景。
    -- ⛔ 改这里之前先确认 poolBaseSql 还引用了哪些别名。
    JOIN patients p ON p.id = fp.patient_id
    JOIN plan_reasons pr ON pr.plan_id = fp.id
    CROSS JOIN LATERAL unnest(pr.potential_labels) AS lab(lbl)
    ${LAST_VISIT_JOIN}
    WHERE ${planFilter}
    GROUP BY 1, 2, 3`;
}

/**
 * 关联片段:外层 `fp`(followup_plans)/ `p`(patients)必须在作用域内。
 *
 * ⭐ **不重新 SELECT followup_plans** —— 直接从 plan_reasons 出发关联 `pr.plan_id = fp.id`,
 *   这样内部没有 `fp` 别名,不会遮蔽外层(那是上面那个函数不能用在这里的原因)。
 */
export function labelExistsSql(label: string): Prisma.Sql {
  return Prisma.sql`
    AND EXISTS (
      SELECT 1
      FROM plan_reasons pr
      WHERE pr.plan_id = fp.id
        AND pr.potential_labels @> ARRAY[${label}]::text[]
    )`;
}

/**
 * 关联片段:该 plan 在 `label` 这一格上的温度落在 `buckets` 之内。
 *
 * 🔴 这里是**圈人**侧,`planLabelAnchorsSql` 是**看**侧 —— 两边必须是同一个锚点。
 *   曾经它们各自带一个 `mode` 参数,一旦调用方漏传就是主管看到 87 人、确认单捞出另一批,
 *   而且全程不报错(T14)。⛔ 别再把口径做成参数:同源的唯一可靠办法是**没有选项**。
 * ⚠️ 走 GROUP BY + HAVING 而不是逐条判 —— 判档表达式里有聚合(`max`),
 *   逐条判再 OR 的语义不一样。⛔ 别为了"看着简单"改成 WHERE。
 */
export function labelTemperatureExistsSql(
  label: string,
  buckets: readonly TemperatureValue[],
): Prisma.Sql {
  return Prisma.sql`
    AND EXISTS (
      SELECT 1
      FROM plan_reasons pr
      ${LAST_VISIT_JOIN}
      WHERE pr.plan_id = fp.id
        AND pr.potential_labels @> ARRAY[${label}]::text[]
      HAVING ${temperatureBucketCaseSql(BOUNDS.hot, BOUNDS.warm, BOUNDS.anchor)} IN (${Prisma.join(
        buckets.map((t) => Prisma.sql`${t}`),
        ', ',
      )})
    )`;
}

