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

/**
 * 「这批人是谁」的**唯一**取数口径 —— 出确认单(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;
  /** 主管在调整阶段追加的画像条件(`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 Prisma.sql`
    AND EXISTS (
      SELECT 1 FROM personas pe
      JOIN persona_features pf ON pf.persona_id = pe.id AND pf.key = 'potential_treatment'
      WHERE ${CURRENT_PERSONA}
        AND (pf.data #> '{types}') @> ${JSON.stringify([potentialTreatment])}::jsonb
    )`;
}

/**
 * Y 轴:窗口温度 —— 读时拿 `now` 跟存好的两个边界时刻比,**不做任何天数运算**。
 * 口径与算法见 `@pac/types` 的 temperature.ts(为什么存边界不存天数/档位)。
 *
 * ⚠️ `hotUntil IS NOT NULL` 是必须的:本次改动之前算出来的老画像没有边界字段,
 *   它们是「温度未知」,⛔ 不许掉进任何一档(尤其不许默认掉进冷),否则主管看到假分布。
 *   调用方应另外把"未知"的条数显式报出来(见 cohort-attributes 的 unknownTemperature)。
 */
function temperatureSql(
  potentialTreatment: string,
  temperature: TemperatureValue,
  now: Date,
): Prisma.Sql {
  // ⭐ 稳定路径 `temperature.<标签>.hotUntil`(不是 detail 数组下标)——
  //   同一个谓词列表页用 Prisma 也能表达,两边因此天然对得上数。
  const hotUntil = Prisma.sql`(pf.data #>> ${[`temperature`, potentialTreatment, 'hotUntil']}::text[])::timestamptz`;
  const warmUntil = Prisma.sql`(pf.data #>> ${[`temperature`, potentialTreatment, 'warmUntil']}::text[])::timestamptz`;
  const phase =
    temperature === Temperature.HOT
      ? Prisma.sql`${now} <= ${hotUntil}`
      : temperature === Temperature.WARM
        ? Prisma.sql`${now} > ${hotUntil} AND ${now} <= ${warmUntil}`
        : Prisma.sql`${now} > ${warmUntil}`;
  return Prisma.sql`
    AND EXISTS (
      SELECT 1 FROM personas pe
      JOIN persona_features pf ON pf.persona_id = pe.id AND pf.key = 'potential_treatment'
      WHERE ${CURRENT_PERSONA}
        AND pf.data #> ${['temperature', potentialTreatment]}::text[] IS NOT NULL
        AND ${phase}
    )`;
}

/**
 * 主管追加的画像条件 —— 同一维度多选 = 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;
}

/**
 * 完整的人群过滤片段 —— 调用方拼在 `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}
  `;
}
