import { Prisma } from '@prisma/client';
import {
  PERSONA_TAG_FILTER_DIMS,
  expandTemperatureFilter,
  Temperature,
  parsePersonaTags,
  personaTagDimId,
  visitRecencyRange,
  type TemperatureValue,
} from '@pac/types';
import type { TenantScopeContext } from '../../common/decorators/tenant-scope.decorator';
import { labelExistsSql, labelTemperatureExistsSql } from './reason-temperature.sql';

/**
 * 「这批人是谁」的**唯一**取数口径 —— 出确认单(assignment-proposal)与看属性分布
 * (cohort-attributes)共用同一份 SQL 片段。
 *
 * ⚠️⚠️ **为什么必须共用**:两处各写一遍,就会出现「属性分布说商保直付 32 人,
 *   提案出来只有 28 人」。主管当场就不信了,而且分不清是哪一边错。
 *   这正是 T6a「同源保证」和验证策略里「口径对数」那一条要防的事。
 *
 * ⚠️ 与列表页(`plan.service.buildListWhere`,Prisma 形态)是**两套实现、一套口径**:
 *   列表页要分页/排序走 Prisma,这里要表达 `jsonb_array_elements` + 时刻比较走原生 SQL,
 *   Prisma 表达不了后者。两边的等价性靠 `tests/cohort-filter.spec.ts` + 本地实测对数守住。
 */

export interface CohortCriteria {
  /** 批次不跨诊所 */
  clinicId: string;
  /** 矩阵 X 轴:画像「潜在治疗」8 类之一 */
  potentialTreatment?: string;
  /** 矩阵 Y 轴:窗口温度。⚠️ 必须与 potentialTreatment 同时给,见下方 assert */
  temperature?: TemperatureValue;
  /**
   * 只要**累计净消费高于这个数(元)**的人 —— 「还能再筛一刀」里那条消费选项。
   *
   * 🔴 为什么不走 `personaTags`:那是个**等值字典**(`data->>path = value`),
   *   表达不了「大于」。⛔ 别为了复用它去硬造一个 `rfm:gt_720` 之类的伪取值 ——
   *   闭集校验会把它静默丢掉(`personaTagsSql` 的 `if (!dim) continue`),筛选等于没加。
   * ⚠️ 单位是**元**,一路到这里都是元,只在 SQL 里 ×100 对上 `monetaryCents`。
   *   ⛔ 别在中途换单位:门槛这个数会原样出现在按钮文案和主管的话里(「消费高于 720 的」),
   *   两处单位不一致 = 差 100 倍且不报错。
   * ⚠️ 没有 rfm 画像的人**筛不出来**(EXISTS 不成立)—— 那是对的:
   *   "不知道他花了多少"不等于"他花得多"。
   */
  minSpendYuan?: number;
  /** 主管在调整阶段追加的画像条件(`key:value` 逗号串,与列表页同格式) */
  personaTags?: string;
}

/**
 * 温度必须挂在某个治疗项上 —— 「热」本身不是患者属性。
 * 同一个人可能「潜在种植·热」而「潜在补牙·冷」,不指定治疗项的「热」是无意义的。
 * ⛔ 不要"贴心地"给个默认治疗项,那会让主管拿到一批他没要的人。
 */
export function assertCohortCriteria(c: CohortCriteria): void {
  if (c.temperature && !c.potentialTreatment) {
    throw new Error('温度必须与潜在治疗项一起给 —— 同一个人可能对种植是热、对补牙是冷。');
  }
}

/** 池子基线:未被认领、未 supersede、冷静期已过的活跃 plan(与列表页 view=pool 同义) */
export function poolBaseSql(scope: TenantScopeContext, clinicId: string): Prisma.Sql {
  return Prisma.sql`
    fp.host_id = ${scope.hostId}::uuid
    AND fp.tenant_id = ${scope.tenantId}
    AND fp.target_clinic_id = ${clinicId}
    AND fp.status = 'active'
    AND fp.assignee_user_id IS NULL
    AND fp.superseded_at IS NULL
    -- 冷静期/约定回访未到 → 不该进池,更不该被分
    AND (fp.snoozed_until IS NULL OR fp.snoozed_until <= NOW())
    ${scope.sourceUnits.length ? Prisma.sql`AND p.source_unit IN (${Prisma.join(scope.sourceUnits)})` : Prisma.empty}
  `;
}

/**
 * 画像必须按**患者**取当前版。
 *
 * 🔴 ⛔ 绝不能写成 `pe.id = fp.persona_id AND pe.superseded_at IS NULL`:
 *   `followup_plans.persona_id` 指向建 plan 那一刻的画像版本,画像一升版本那行就被 supersede,
 *   而 plan 的外键不跟着走 → 条件恒为假 → 筛选静默变 0 条,不报错不告警。
 *   实测见教条 §4.38(全量重算后 2,724 条池子里只剩 97 条还指向活版本)。
 */
const CURRENT_PERSONA = Prisma.sql`pe.patient_id = fp.patient_id AND pe.superseded_at IS NULL`;

/** X 轴:潜在治疗(data->'types' 是字符串数组,与 persona-tag-filters 同口径用 @> 命中) */
function treatmentSql(potentialTreatment: string): Prisma.Sql {
  return labelExistsSql(potentialTreatment);
}

/**
 * Y 轴:窗口温度 —— 读时拿 `now` 跟存好的两个边界时刻比,**不做任何天数运算**。
 * 口径与算法见 `@pac/types` 的 temperature.ts(为什么存边界不存天数/档位)。
 *
 * ⚠️ `hotUntil IS NOT NULL` 是必须的:本次改动之前算出来的老画像没有边界字段,
 *   它们是「温度未知」,⛔ 不许掉进任何一档(尤其不许默认掉进冷),否则主管看到假分布。
 *   调用方应另外把"未知"的条数显式报出来(见 cohort-attributes 的 unknownTemperature)。
 */
function temperatureSql(
  potentialTreatment: string,
  temperature: TemperatureValue,
  now: Date,
): Prisma.Sql {
  void now; // 判档一律用 SQL 的 NOW() —— 与矩阵同一个时钟,⛔ 别掺 JS 时刻(边界人群会漂)
  // ⚠️ 旧取值 `cold` = 四个冷档的并集(API 契约不断);其余原样单档
  return labelTemperatureExistsSql(potentialTreatment, expandTemperatureFilter(temperature));
}

/**
 * 主管追加的画像条件 —— 同一维度多选 = OR,跨维度 = AND(与列表页语义完全一致)。
 * 维度/取值以 `PERSONA_TAG_FILTER_DIMS` 为单一真理源,非法 key/value 静默丢弃。
 */
function personaTagsSql(personaTags: string, now: Date): Prisma.Sql {
  // ⚠️ 按**维度 id** 分组而不是 persona 特征 key —— visit_recency 一个特征出三个可筛字段,
  //   按 key 分组会把它们串成一条 OR,筛出错的人(列表页踩过,这里照抄结论)。
  const byDim = new Map<string, { dim: (typeof PERSONA_TAG_FILTER_DIMS)[number]; values: string[] }>();
  for (const { key: dimId, value } of parsePersonaTags(personaTags)) {
    const dim = PERSONA_TAG_FILTER_DIMS.find((d) => personaTagDimId(d) === dimId);
    if (!dim) continue;
    if (!dim.dynamic && !dim.options.some((o) => o.value === value)) continue;
    const cur = byDim.get(dimId) ?? { dim, values: [] };
    cur.values.push(value);
    byDim.set(dimId, cur);
  }

  const frags: Prisma.Sql[] = [];
  for (const { dim, values } of byDim.values()) {
    // ── 事实派生属性:直接查 patient_profiles,不穿 personas → persona_features ──
    if (dim.source === 'patient') {
      if (dim.patientField === 'lastVisitAt') {
        // ⭐ 存的是日期,区间按 now 现算(与 visitRecencyRange 同一个函数)——
        //   跟温度存边界时刻是同一条道理:存死分档会过期。
        const ranges = values
          .map((v) => visitRecencyRange(v, now))
          .filter((r): r is { gte?: Date; lt?: Date } => !!r);
        if (ranges.length === 0) continue;
        const ors = ranges.map((r) =>
          Prisma.sql`(${r.gte ? Prisma.sql`pp.last_visit_at >= ${r.gte}` : Prisma.sql`TRUE`}
                  AND ${r.lt ? Prisma.sql`pp.last_visit_at < ${r.lt}` : Prisma.sql`TRUE`})`,
        );
        frags.push(Prisma.sql`AND EXISTS (
          SELECT 1 FROM patient_profiles pp
          WHERE pp.patient_id = fp.patient_id AND (${Prisma.join(ors, ' OR ')}))`);
      } else {
        const col =
          dim.patientField === 'lastVisitDoctor'
            ? Prisma.sql`pp.last_visit_doctor`
            : Prisma.sql`pp.preferred_doctor`;
        frags.push(Prisma.sql`AND EXISTS (
          SELECT 1 FROM patient_profiles pp
          WHERE pp.patient_id = fp.patient_id AND ${col} IN (${Prisma.join(values)}))`);
      }
      continue;
    }

    const ors = values.map((v) =>
      dim.isArray
        ? Prisma.sql`(pf.data #> ${`{${dim.dataPath}}`}::text[]) @> ${JSON.stringify([v])}::jsonb`
        : Prisma.sql`pf.data ->> ${dim.dataPath} = ${v}`,
    );
    frags.push(Prisma.sql`AND EXISTS (
      SELECT 1 FROM personas pe
      JOIN persona_features pf ON pf.persona_id = pe.id AND pf.key = ${dim.key}
      WHERE ${CURRENT_PERSONA} AND (${Prisma.join(ors, ' OR ')}))`);
  }
  return frags.length ? Prisma.sql`${Prisma.join(frags, ' ')}` : Prisma.empty;
}

/**
 * 累计净消费下界 —— 与 `personaTagsSql` **同一个 EXISTS 形状**(走当前版画像)。
 *
 * ⚠️ `(pf.data->>'monetaryCents')::bigint` —— 存的是**分**,所以这里 ×100。
 *   ⛔ 别写成 `::int`:上限 21 亿分 = ¥2,147 万,单个患者够不着但迟早有人踩;bigint 没这个顾虑。
 * ⚠️ 用 `>` 不是 `>=` —— 文案说的是「高于平均」,含等号就跟文案对不上了(平均值那个人会被算进去)。
 * ⚠️ 键名 `monetaryCents` 与 `rfm.feature.ts` 写进 data 的那个键必须逐字一致,
 *   ⛔ 拼错不会报错,只会**筛出 0 人**(JSON 取不到键 → NULL → 比较为假)。
 */
function minSpendSql(yuan: number): Prisma.Sql {
  return Prisma.sql`AND EXISTS (
    SELECT 1 FROM personas pe
    JOIN persona_features pf ON pf.persona_id = pe.id AND pf.key = 'rfm'
    WHERE ${CURRENT_PERSONA}
      AND (pf.data ->> 'monetaryCents')::bigint > ${Math.round(yuan * 100)})`;
}

/**
 * 完整的人群过滤片段 —— 调用方拼在 `FROM followup_plans fp JOIN patients p ON p.id = fp.patient_id WHERE …` 之后。
 */
export function cohortWhereSql(
  scope: TenantScopeContext,
  c: CohortCriteria,
  now: Date,
): Prisma.Sql {
  assertCohortCriteria(c);
  return Prisma.sql`
    ${poolBaseSql(scope, c.clinicId)}
    ${c.potentialTreatment ? treatmentSql(c.potentialTreatment) : Prisma.empty}
    ${
      c.temperature && c.potentialTreatment
        ? temperatureSql(c.potentialTreatment, c.temperature, now)
        : Prisma.empty
    }
    ${c.personaTags ? personaTagsSql(c.personaTags, now) : Prisma.empty}
    ${c.minSpendYuan != null && c.minSpendYuan > 0 ? minSpendSql(c.minSpendYuan) : Prisma.empty}
  `;
}
