import { Injectable, NotFoundException } from '@nestjs/common';
import type { Prisma } from '@prisma/client';
import { calcAge, maskName, maskPhone } from '@pac/utils';
import {
  applyLiveDays,
  ApiCode,
  focusOrderReasons,
  KIN_RELATIONSHIPS,
  resolveKinRelationship,
} from '@pac/types';
import { BizError } from '../../common/errors/biz-error';
import { PrismaService } from '../../prisma/prisma.service';
import { ChainComposerService } from '../plan/engine/chain-composer.service';
import type { TenantScopeContext } from '../../common/decorators/tenant-scope.decorator';
import { toothSet } from '../sync/pipeline/parsers/tooth-position.util';
import { renderAgentIdentity } from '../ai/calls/draft-plan-script/shared/agent-identity';
import type { ScriptAgentIdentity } from '../ai/calls/draft-plan-script/shared/agent-identity';

/**
 * PlanAggregateService — Plan 详情聚合服务。
 *
 * 一次拉全:patient + profile + plan + reasons + persona + features + facts + chains。
 *
 * 注:命名是历史(早期只给 demo 页用),现在也是 (app)/plans/[planId] 的数据源。
 * 治疗链合成委托给 ChainComposerService(共享逻辑,PlanEngine / PersonaService 同源)。
 */
@Injectable()
export class PlanAggregateService {
  constructor(
    private readonly prisma: PrismaService,
    private readonly chainComposer: ChainComposerService,
  ) {}

  /**
   * 按 planId 查 → 反查 patient → 同 assemble。
   * (app)/plans/[planId] 路由 + PlansAggregateController 的入口。
   */
  async getPlanDetailByPlanId(
    scope: TenantScopeContext,
    planId: string,
    /** 当前登录客服 —— 话术里的【回访客服】占位按它回填;缺省 → 通用"客服" */
    agent?: ScriptAgentIdentity,
  ) {
    const plan = await this.prisma.followupPlan.findFirst({
      // 集团模型:by-id 也按品牌圈(plan→patient.sourceUnit)
      where: {
        id: planId,
        ...(scope.sourceUnits.length ? { patient: { sourceUnit: { in: scope.sourceUnits } } } : {}),
      },
      include: {
        reasons: { orderBy: { priorityScore: 'desc' } },
        // ⭐ 批次意图:聚焦哪条 reason 要看"这单是通过哪一格分下来的"(见下方 focusOrderReasons)。
        //   只取 status/criteria 两列 —— 不要 include 整个 assignment,它带 attributes(福利文案)等
        //   与本处无关的字段,白白进内存。
        assignment: { select: { status: true, criteria: true } },
      },
    });
    if (!plan) throw new NotFoundException(`Plan ${planId} not found`);
    if (plan.hostId !== scope.hostId || plan.tenantId !== scope.tenantId) {
      throw new NotFoundException(`Plan ${planId} not found`);
    }
    // clinic 隔离:与召回池列表一致 —— orgScope→clinicIds 硬边界,目标诊所须在 clinicIds 内或为
    //   null(集团池)。否则按 ID 直取也不可见 —— 修"切诊所后残留旧 URL 越权看到别诊所患者"
    //   (view_all 不豁免:clinicIds 由 orgScope 派生,宽 scope 天然覆盖全部,窄 scope 才被圈住)。
    //   抛 PLAN_NOT_FOUND(非普通 404):前端据此 router.push('/plans') → landing 选回当前 scope 首个患者。
    if (scope.clinicIds.length > 0 && plan.targetClinicId && !scope.clinicIds.includes(plan.targetClinicId)) {
      throw new BizError(ApiCode.PLAN_NOT_FOUND, `Plan ${planId} 不在当前诊所范围`);
    }
    const patient = await this.prisma.patient.findUnique({
      where: { id: plan.patientId },
      include: {
        profile: true,
        // 联系人/亲属边 → 关系人姓名/电话从 relatedPatient 现取(patient↔patient,零冗余)
        relationsOut: {
          include: {
            relatedPatient: {
              // externalId / 病历号:给宿主 VIEW_PATIENT 槽位的占位替换用(同本人「原始档案」那条路)
              select: {
                id: true,
                externalId: true,
                medicalRecordNumber: true,
                name: true,
                phone: true,
                birthDate: true,
                gender: true,
              },
            },
          },
        },
        // 诊所回访记录(展示用,按时间倒序,封顶 100;每患者 p99=19,够用)
        // nulls:'last' —— Postgres 的 DESC 默认 NULLS FIRST,无日期的记录会顶到列表最前,
        // 被前端当成「最近一次」;无日期的当最老处理,跟摘要侧口径保持一致。
        returnVisits: { orderBy: { taskDate: { sort: 'desc', nulls: 'last' } }, take: 100 },
      },
    });
    if (!patient) throw new NotFoundException(`Patient ${plan.patientId} not found`);

    // supersede 跳转:plan 重算后会 supersede 出新版本(新 id),老 plan 行仍保留冻结快照。
    // 老 plan URL(收藏 / 分享 / 任务抽屉 / 刷新后 refetch)若直接渲染会显示陈旧数据。
    // → 检测到请求的是 superseded plan,解析该患者当前 active/assigned plan,响应带 currentPlanId,
    //   前端 router.replace 落到最新版(单 patient 仅一个活跃 plan,partial UNIQUE 保证)。
    let currentPlanId = plan.id;
    if (plan.status === 'superseded') {
      const active = await this.prisma.followupPlan.findFirst({
        where: {
          patientId: plan.patientId,
          hostId: scope.hostId,
          tenantId: scope.tenantId,
          status: { in: ['active', 'assigned'] },
        },
        orderBy: { version: 'desc' },
        select: { id: true },
      });
      if (active) {
        currentPlanId = active.id; // 有后继活跃版本 → 前端 router.replace 切过去
      } else {
        // superseded 且无后继活跃版本 = 该召回已关闭(缺口解决/退池)→ 报 PLAN_NOT_FOUND,
        // 前端按已有逻辑提示"该召回已不存在,返回召回池"(不再静默渲染冻结的关闭快照)。
        throw new BizError(ApiCode.PLAN_NOT_FOUND, `Plan ${planId} 已关闭(无活跃后继版本)`);
      }
    }

    /**
     * ⭐ 聚焦项按**批次意图**重排 —— 主管点「补牙」这一格分下来的单,客服打开就该先看补牙,
     *   哪怕这人身上缺牙的分更高(实测补牙格 49% 的单不是以补牙为主因)。
     * ⚠️ 判据是 `status === 'confirmed'`:撤销后批次意图作废,回落"分最高"。
     *   与话术带不带福利同一个判据(plan-script.orchestrator 的 benefit),⛔ 别另立标准。
     * ⚠️ 前端拿到后**还会再排一次**,那边必须走同一个函数,否则这里白改(见 plan-detail-app)。
     */
    const focusLabel =
      plan.assignment?.status === 'confirmed'
        ? ((plan.assignment.criteria as { potentialTreatment?: string } | null)?.potentialTreatment ?? null)
        : null;
    const focused = { ...plan, reasons: focusOrderReasons(plan.reasons, focusLabel), focusLabel };

    const assembled = await this.assemble(scope, patient, focused, agent);
    return { ...assembled, currentPlanId };
  }

  // ─────────────────────────────────────────────
  // 共享 assemble(patient + plan 已加载,组装其他)
  // ─────────────────────────────────────────────

  private async assemble(
    scope: TenantScopeContext,
    patient: Prisma.PatientGetPayload<{
      include: {
        profile: true;
        relationsOut: {
          include: {
            relatedPatient: {
              select: {
                id: true;
                externalId: true;
                medicalRecordNumber: true;
                name: true;
                phone: true;
                birthDate: true;
                gender: true;
              };
            };
          };
        };
        returnVisits: true;
      };
    }>,
    plan: Prisma.FollowupPlanGetPayload<{ include: { reasons: true } }> | null,
    agent?: ScriptAgentIdentity,
  ) {
    const persona = await this.loadCurrentPersona(patient.id);
    const facts = await this.loadActiveFacts(scope, patient.id);
    // W4:话术从 DB 加载(LLM 流式生成完会 upsert 到 plan_scripts)
    // 没生成过 → script=null,前端走 mock 兜底
    const scriptRow = plan ? await this.loadPlanScript(plan.id) : null;
    // ⚠️ 企微稿**独立一行**(channel='wecom'),与电话稿各有各的 status ——
    //   ⛔ 别在这里做"企微没有就回落电话稿":那份是口语分段的,复制发给患者很怪,
    //   而客服不会注意到自己发错了东西。没有就是 null,前端如实显示。
    const wecomRow = plan ? await this.loadPlanScript(plan.id, 'wecom') : null;
    // ⭐ 落库正文里的自报家门是占位符【回访客服】,读出来按**当前登录人**回填 —— 缓存 per-plan、
    //   召回池共享,烤进人名会让后开的客服看到别人的名字(见 agent-identity.ts)
    const script = scriptRow
      ? { ...scriptRow, content: renderAgentIdentity(scriptRow.content, agent) }
      : null;
    // v2.1:chain-composer 读独立 diagnosis_record / treatment_record / recommendation_record,
    //       传所有 facts,内部按 type 分组(encounter_record 已只元数据)
    const chains = this.chainComposer.compose(facts);
    // ⭐ SQL 为准:chain ★ 按 plan_reasons (code, tooth) 联合对齐(W4 末)
    //
    // 规则:reason 是 (code + tooth) tuple → 找 chain.code 相同 + tooth overlap
    //   林兆星案例:
    //     K08 reason tooth=17(SQL 召回的是这颗未做)
    //     chain 有 4 条 K08:11;12;21;22 / 15;16;24 / 17 / 未标注
    //     正确 target:只有 chain 17 应 ★(其他都已做 actual implant,SQL ⑤a 排除)
    //   K05 reason tooth=null(全口诊断)→ 任何 K05 chain 都对齐(只 1 条牙周·全口)
    //
    // 1 年前 fulfilled 预约让 chain 算 entered 也照样 ★(SQL 是真理 — 没坚持治疗就该召回)
    const reasonKeys = new Set<string>(); // key = "code|tooth" or "code|*"(全口)
    for (const r of plan?.reasons ?? []) {
      const sigs = (r.signals ?? {}) as { triggers?: Array<{ code?: string }>; toothPosition?: string | null };
      const tooth = (sigs.toothPosition ?? '').trim();
      for (const t of sigs.triggers ?? []) {
        if (!t.code) continue;
        reasonKeys.add(`${t.code}|${tooth || '*'}`);
      }
    }
    // W4 末:牙位精确匹配(Palmer 乳牙 + FDI 恒牙都支持)
    //   旧版 strip 所有字母 → "1D OD" → "1" → Palmer 乳牙 1A/1B/1C/1D/1E 全部误匹配
    //   新版用 toothSet:剥面后缀后保留牙位 base("1D OD" → "1D"; "11 M" → "11")
    //   精确匹配避免 Palmer 5 颗乳牙在儿童 case 被误判
    const toothOverlap = (a: string, b: string): boolean => {
      if (!a || !b) return false;
      const A = toothSet(a);
      if (A.size === 0) return false;
      for (const t of toothSet(b)) {
        if (A.has(t)) return true;
      }
      return false;
    };
    for (const c of chains) {
      if (!c.code) { c.target = false; continue; }
      // closed chain 永不 ★(临床已结束;即使 SQL 召回也是老 plan/data race 异常)
      // K04 46 拔了/被替代关闭 → 即使老 plan reason 还含 K04 也不该 focused
      if (c.status === 'closed') { c.target = false; continue; }
      let hit = false;
      for (const key of reasonKeys) {
        const [code, tooth] = key.split('|');
        if (code !== c.code) continue;
        // reason 无牙位(全口诊断 K05)→ 任何 chain.code 同就命中
        if (tooth === '*') { hit = true; break; }
        // reason 有牙位 → 跟 chain.toothPosition overlap
        if (c.toothPosition && toothOverlap(tooth!, c.toothPosition)) { hit = true; break; }
      }
      c.target = hit;
    }

    // 召回历史(患者级,跨所有 plan 版本)—— 再次召回是新 plan id,旧执行挂在旧 plan 上,
    // 必须按 patient 取才看得到"上次为什么被压住 / 上次结果"。也顺带补上之前缺失的通话历史。
    const recallHistory = await this.loadRecallHistory(scope, patient.id);

    // 品牌 GUID:source_unit(名) → host.orgAliases[名]。给 EMR 跳转链接的 {brandId} 用
    // (契约里品牌层传的是 GUID;PAC 内部存的是名字,靠 orgAliases 反解)。
    const hostRow = await this.prisma.host.findUnique({
      where: { id: scope.hostId },
      select: { orgAliases: true },
    });
    const orgAliases = (hostRow?.orgAliases ?? {}) as Record<string, string>;
    const brandId = patient.sourceUnit ? (orgAliases[patient.sourceUnit] ?? null) : null;

    // 关联客户(亲戚)可点进去的工单 —— 一条 SQL 批量查,别在 serialize 里逐个 await。
    //   scope 必须带上:关系人可能在别的品牌/诊所,越权的链接点进去是 404,不如不给。
    const relatedPlanIdByPatient = await this.loadRelatedPlanIds(
      scope,
      (patient.relationsOut ?? [])
        .filter((r) => r.relatedPatientId && KIN_RELATIONSHIPS.includes(r.relationship))
        .map((r) => r.relatedPatientId!),
    );

    return {
      patient: { ...serializePatient(patient), brandId },
      profile: serializeProfile(
        patient,
        facts,
        facts.filter((f) => f.type === 'encounter_record'),
        relatedPlanIdByPatient,
      ),
      plan: plan ? serializePlan(plan) : null,
      persona: persona ? serializePersona(persona) : null,
      chains,
      facts: facts.map(serializeFact),
      script: script ? serializeScript(script) : null,
      /**
       * 企微稿 —— ⛔ **不能走 `serializeScript`**(踩过:前端拿到 content 长度为 0)。
       * 那个序列化器会把正文 `parseScriptMarkdownToSections` 拆成段、**并丢掉原文** ——
       * 电话稿要的就是段,而企微稿**只有原文**,拆完就什么都不剩了。
       * 这里原样透出 content(仍要回填【回访客服】占位:落库存的是占位,读时按登录人渲染)。
       */
      wecomScript: wecomRow
        ? {
            id: wecomRow.id,
            status: wecomRow.status,
            content: renderAgentIdentity(wecomRow.content, agent),
            updatedAt: wecomRow.updatedAt.toISOString(),
          }
        : null,
      recallHistory,
      returnVisits: (patient.returnVisits ?? []).map((r) => ({
        taskDate: r.taskDate ? r.taskDate.toISOString().slice(0, 10) : null,
        type: r.type,
        status: r.status,
        taskStatus: r.taskStatus,
        treatmentItems: r.treatmentItems,
        followContent: r.followContent,
        result: r.result,
      })),
    };
  }

  /**
   * 患者级召回历史 —— 最近 N 次执行结果(跨该患者所有 plan 版本)。
   * 用途:plan 再次被召回时,客服能看到"上次召回结果 / 为什么被暂缓"(终态 outcome 即原因)。
   */
  private async loadRecallHistory(scope: TenantScopeContext, patientId: string) {
    const rows = await this.prisma.planExecution.findMany({
      where: { hostId: scope.hostId, tenantId: scope.tenantId, plan: { patientId } },
      orderBy: { createdAt: 'desc' },
      take: 8,
      select: {
        id: true,
        outcome: true,
        channel: true,
        notes: true,
        scheduledNextAt: true,
        createdAt: true,
        plan: { select: { version: true } },
      },
    });
    return rows.map((e) => ({
      id: e.id,
      outcome: e.outcome,
      channel: e.channel,
      notes: e.notes,
      scheduledNextAt: e.scheduledNextAt?.toISOString() ?? null,
      createdAt: e.createdAt.toISOString(),
      planVersion: e.plan?.version ?? null,
    }));
  }

  /**
   * W4:加载该 plan 的最新 ready 话术(LLM 生成完会 upsert 进 plan_scripts)。
   * pending/failed 的不返回 — 前端走 mock 兜底,客服点"重新生成"再触发 LLM。
   */
  private loadPlanScript(planId: string, channel: 'phone' | 'wecom' = 'phone') {
    return this.prisma.planScript.findUnique({
      where: { planId_channel: { planId, channel } },
    });
  }

  // ─────────────────────────────────────────────
  // 数据加载(分小函数 — 便于测试 + 单一职责)
  // ─────────────────────────────────────────────

  /**
   * 关系人 patientId → 本 scope 内可打开的 plan id。
   *
   * 为什么要过 scope:关系人跟本人不一定同品牌/同诊所(家属在别家看的很常见)。
   * 不过滤就会给出一个点进去 404 的链接 —— 那比"没有链接"更糟,客服会以为系统坏了。
   * 查不到的患者不进 Map,前端据此只渲染姓名、不渲染链接。
   * 同一患者多条活跃工单取最新那条(客服要打开的是"这个人现在的工单")。
   */
  private async loadRelatedPlanIds(
    scope: TenantScopeContext,
    patientIds: string[],
  ): Promise<Map<string, string>> {
    const out = new Map<string, string>();
    if (patientIds.length === 0) return out;
    const rows = await this.prisma.followupPlan.findMany({
      where: {
        patientId: { in: [...new Set(patientIds)] },
        hostId: scope.hostId,
        tenantId: scope.tenantId,
        supersededAt: null,
        status: { in: ['active', 'assigned'] },
        ...(scope.sourceUnits.length ? { patient: { sourceUnit: { in: scope.sourceUnits } } } : {}),
      },
      select: { id: true, patientId: true },
      orderBy: { createdAt: 'desc' },
    });
    for (const r of rows) if (!out.has(r.patientId)) out.set(r.patientId, r.id);
    return out;
  }

  private loadCurrentPersona(patientId: string) {
    return this.prisma.persona.findFirst({
      where: { patientId, supersededAt: null },
      include: { features: { orderBy: { createdAt: 'asc' } } },
      orderBy: { version: 'desc' },
    });
  }

  private loadActiveFacts(scope: TenantScopeContext, patientId: string) {
    return this.prisma.patientFact.findMany({
      where: {
        hostId: scope.hostId,
        tenantId: scope.tenantId,
        patientId,
        // ⚠️ 不能只 active — treatment_record(actual) 完成后 status='fulfilled',
        // appointment_record 到诊后也 fulfilled。这些都属"现行已发生事实"应展示。
        // 排除的是被 superseded / cancelled / expired / invalidated 的历史/失效版本。
        status: { in: ['active', 'fulfilled'] },
      },
      orderBy: { occurredAt: 'desc' },
    });
  }
}

// ─────────────────────────────────────────────
// Serializers(纯函数 — 易测、无副作用)
// ─────────────────────────────────────────────

/// 从 patients.preferences JSON 安全解析专属客服(摄入时 mergePatientPreferences 写入)
function extractDedicatedCs(prefs: unknown): { id: string; name: string } | null {
  if (!prefs || typeof prefs !== 'object') return null;
  const d = (prefs as Record<string, unknown>).dedicatedCs;
  if (!d || typeof d !== 'object') return null;
  const name = String((d as Record<string, unknown>).name ?? '').trim();
  const id = String((d as Record<string, unknown>).id ?? '').trim();
  return name ? { id, name } : null;
}

function serializePatient(patient: {
  id: string;
  externalId: string;
  medicalRecordNumber: string | null;
  name: string | null;
  gender: string | null;
  birthDate: Date | null;
  phone: string | null;
  phoneVerified: boolean;
  active: boolean;
  preferences: unknown;
  profile: { tags: string[] } | null;
}) {
  return {
    id: patient.id,
    externalId: patient.externalId,
    /// 病历号(纸质档案 / 客服核对身份用的可读编号,如 "SH0Q011691");≠ externalId(host patient_id)
    medicalRecordNumber: patient.medicalRecordNumber,
    name: patient.name,
    nameMasked: maskName(patient.name),
    gender: patient.gender,
    birthDate: patient.birthDate?.toISOString().slice(0, 10) ?? null,
    age: patient.birthDate ? calcAge(patient.birthDate) : null,
    phone: patient.phone,
    phoneMasked: maskPhone(patient.phone),
    /// 号码真实性:true = 外部对照表核实的真号;false = 宿主同步号(测试库为造数假号)
    phoneVerified: patient.phoneVerified,
    active: patient.active,
    /// 专属客服({id,name})— 从 preferences.dedicatedCs 解析;无则 null
    dedicatedCs: extractDedicatedCs(patient.preferences),
    tags: patient.profile?.tags ?? [],
  };
}

function serializeProfile(
  patient: {
    birthDate: Date | null;
    profile: {
      doNotContact: boolean;
      deceased: boolean;
      notes: string | null;
    } | null;
    relationsOut?: Array<{
      relationship: string;
      relatedExternalId: string;
      relatedPatientId: string | null;
      relatedPatient: {
        id: string;
        externalId: string;
        medicalRecordNumber: string | null;
        name: string | null;
        phone: string | null;
        birthDate: Date | null;
        gender: string | null;
      } | null;
    }>;
  },
  allFacts: Array<{ type: string; content: Prisma.JsonValue; occurredAt: Date | null }>,
  encounters: Array<{ occurredAt: Date | null; content: Prisma.JsonValue }>,
  /** 关系人 patientId → 本 scope 内可打开的 plan id(查不到/越权 → 无此键,前端不给链接) */
  relatedPlanIdByPatient: Map<string, string> = new Map(),
) {
  if (!patient.profile) return null;

  const payments = allFacts.filter((f) => f.type === 'payment_record');
  const recharges = allFacts.filter((f) => f.type === 'recharge_record');
  const refunds = allFacts.filter((f) => f.type === 'refund_record');
  const ltvCents = sumAmount(payments) + sumAmount(recharges) - sumAmount(refunds);

  // 就诊时间口径:
  //   优先用 encounter_record(appointment 显式标"已到诊"的)
  //   兜底 emr_record(医生写病历必然患者到场,assembler 没归 encounter 时不丢)
  //   场景:DW 部分 host 的 appointment.in_time 字段缺失,但 EMR 完整
  //        例 张吴双 — 0 encounter / 39 emr,encounter 兜底空,就诊时间应来自 EMR
  const allVisitFacts = [...encounters, ...allFacts.filter((f) => f.type === 'emr_record')]
    .filter((f) => f.occurredAt)
    .sort((a, b) => (b.occurredAt!.getTime() - a.occurredAt!.getTime()));
  const latestEncounter = allVisitFacts[0];
  const firstEncounter = allVisitFacts.at(-1);
  const daysSinceLatestVisit = latestEncounter?.occurredAt
    ? Math.floor((Date.now() - latestEncounter.occurredAt.getTime()) / 86400_000)
    : null;

  const doctors = Array.from(
    new Set(
      encounters
        .map((e) => (e.content as Record<string, unknown> | null)?.doctor_id as string | undefined)
        .filter(Boolean) as string[],
    ),
  ).slice(0, 2);

  // 联系人/亲属:本人视角(relatedPatient 是本人的 X)。姓名/电话/生日从 relatedPatient 现取。
  //   linked=false(关系人未建档)→ 无姓名,前端展示时跳过。已建档的排前。
  //   ⭐ isKin:是不是**亲戚**(KIN_RELATIONSHIPS 白名单)。详情页「关联客户」只列亲戚 ——
  //     friend / other 那两类里混的是推荐人噪音(other 占了边表一半以上),列出来客服会误当家属。
  //   ⭐ planId:关系人在**本 scope 内**能打开的工单;没有(没建工单 / 不在数据范围)→ null,
  //     前端就不给链接,免得点进去 404。
  const selfAge = patient.birthDate ? calcAge(patient.birthDate) : null;
  const contacts = (patient.relationsOut ?? [])
    .map((r) => {
      const relAge = r.relatedPatient?.birthDate ? calcAge(r.relatedPatient.birthDate) : null;
      // ⭐ 关系方向按**年龄实时判**,不信源里填的方向 —— FRIDAY 侧 97% 的父母/子女对是反的
      //   (根因在 data/friday/assemblers/patient_relation.yaml 多做了一次逆关系映射,摄入侧要修;
      //    展示侧不能等:说错亲属关系,客服在通话中会当场出丑)。口径见 resolveKinRelationship。
      const { relationship, ageCorrected } = resolveKinRelationship(
        r.relationship,
        selfAge,
        relAge,
        r.relatedPatient?.gender ?? null,
      );
      return {
        relationship,
        relationshipLabel: RELATIONSHIP_LABEL[relationship] ?? relationship,
        /** 源数据原样的关系码 —— 纠正过时留着,排查 / 跟宿主对账用 */
        relationshipRaw: r.relationship,
        ageCorrected,
        name: r.relatedPatient?.name ?? null,
        phone: r.relatedPatient?.phone ?? null,
        linked: !!r.relatedPatientId,
        isKin: KIN_RELATIONSHIPS.includes(relationship),
        age: relAge,
        relatedPatientId: r.relatedPatientId,
        // 宿主 VIEW_PATIENT 槽位的占位值 —— 前端配了就跳宿主档案,没配才回落 PAC 工单页
        externalId: r.relatedPatient?.externalId ?? r.relatedExternalId,
        medicalRecordNumber: r.relatedPatient?.medicalRecordNumber ?? null,
        planId: r.relatedPatientId ? (relatedPlanIdByPatient.get(r.relatedPatientId) ?? null) : null,
      };
    })
    .sort((a, b) => Number(b.linked) - Number(a.linked));

  // ⚠️⚠️ TEST ONLY ⚠️⚠️ 监护人手机 fallback(详见 pickGuardianTestOnly 注释)。
  //   真实手机号宿主未提供(现随机生成),此判定仅用于验证"本人无手机→打监护人"的 UX。
  //   上线前必须移除/重做:① 宿主提供真实手机 + 专门的监护人/紧急联系人字段;② 确认业务规则。
  const guardian = pickGuardianTestOnly(patient.birthDate ? calcAge(patient.birthDate) : null, contacts);

  return {
    doNotContact: patient.profile.doNotContact,
    deceased: patient.profile.deceased,
    notes: patient.profile.notes,
    ltvCents,
    paymentCount: payments.length,
    rechargeCount: recharges.length,
    refundCount: refunds.length,
    firstVisit: firstEncounter?.occurredAt?.toISOString().slice(0, 10) ?? null,
    lastVisit: latestEncounter?.occurredAt?.toISOString().slice(0, 10) ?? null,
    daysSinceLatestVisit,
    doctors,
    contacts,
    guardian, // ⚠️ TEST ONLY
  };
}

/// ⚠️⚠️ TEST ONLY ⚠️⚠️ 监护人判定 — 真实手机号到位前仅用于演示 fallback UX,勿当正式特性。
///   目的:本人(儿童/老人)往往无自己手机,触达要打监护人。从「已关联(关系人已建档,
///   故有姓名+电话)」联系人里按人群挑监护人:
///     · 儿童(<18):父母 > 祖辈     · 老人(>=60):配偶 > 子女 > 孙辈
///     · 成人不判(本人自有手机)
///   注:当前 phone 是随机生成的假数据,此处只演示"手机号旁标注(身份 姓名)"的交互。
function pickGuardianTestOnly(
  age: number | null,
  contacts: Array<{
    relationship: string;
    relationshipLabel: string;
    name: string | null;
    phone: string | null;
    linked: boolean;
  }>,
): { relationship: string; relationshipLabel: string; name: string | null; phone: string | null } | null {
  if (age == null) return null;
  let priority: string[];
  if (age < 18) priority = ['mother', 'father', 'grandparent', 'guardian'];
  else if (age >= 60) priority = ['spouse', 'child', 'grandchild'];
  else return null;
  const linked = contacts.filter((c) => c.linked && c.name);
  for (const rel of priority) {
    const hit = linked.find((c) => c.relationship === rel);
    if (hit) {
      return {
        relationship: hit.relationship,
        relationshipLabel: hit.relationshipLabel,
        name: hit.name,
        phone: hit.phone,
      };
    }
  }
  return null;
}

/// 关系枚举 → 中文展示标签(本人视角:对方是本人的 X)
const RELATIONSHIP_LABEL: Record<string, string> = {
  mother: '妈妈',
  father: '爸爸',
  grandparent: '祖辈',
  spouse: '配偶',
  child: '子女',
  parent: '父母', // 年龄纠正出来的中性长辈码(关系人性别缺失时用;源数据里没有这个码)
  sibling: '兄弟姐妹',
  grandchild: '孙辈',
  friend: '朋友',
  other: '亲属',
};

function serializePlan(plan: {
  id: string;
  version: number;
  status: string;
  priorityScore: number;
  contactAttempts: number;
  targetClinicId: string | null;
  goal: string | null;
  recommendedAt: Date | null;
  recommendedRole: string | null;
  recommendedChannel: string | null;
  assigneeUserId: string | null;
  assignedAt: Date | null;
  recycleAt: Date | null;
  assignmentExpiresAt: Date | null;
  snoozedUntil: Date | null;
  recallFeedback: string | null;
  recallFeedbackNote: string | null;
  /// 批次意图(主管点的那一格)——⚠️ reasons **已按它排好序**,前端重排必须走同一个 focusOrderReasons
  focusLabel?: string | null;
  updatedAt: Date;
  reasons: Array<{
    id: string;
    scenario: string;
    subKey: string | null;
    priorityScore: number;
    reason: string;
    lifecycle: string;
    source: string;
    evidence: Prisma.JsonValue;
    breakdown: Prisma.JsonValue;
    signals: Prisma.JsonValue;
  }>;
}) {
  return {
    id: plan.id,
    version: plan.version,
    status: plan.status,
    priorityScore: plan.priorityScore,
    contactAttempts: plan.contactAttempts,
    maxContactAttempts: 4, // 应用层默认(后续可挂 host 配置)
    targetClinicId: plan.targetClinicId,
    goal: plan.goal,
    /// 该 plan 版本的重算时间 — UI "更新于 X" 渲染数据新鲜度
    updatedAt: plan.updatedAt.toISOString(),
    recommendedAt: plan.recommendedAt?.toISOString() ?? null,
    recommendedRole: plan.recommendedRole,
    recommendedChannel: plan.recommendedChannel,
    assigneeUserId: plan.assigneeUserId,
    assignedAt: plan.assignedAt?.toISOString() ?? null,
    recycleAt: plan.recycleAt?.toISOString() ?? null,
    /// ⭐ 分配单到期时刻(客服手上这单还剩多久);⚠️ 与 recycleAt 是两个机制,见 schema 注释
    assignmentExpiresAt: plan.assignmentExpiresAt?.toISOString() ?? null,
    /// 召回冷静期 / 终态抑制窗到期时间(null=无抑制)— 详情页可渲染"已抑制至 / 下次回访 X"
    snoozedUntil: plan.snoozedUntil?.toISOString() ?? null,
    /// 召回反馈(plan 级)— 详情页标题栏拇指当前态;'up' | 'down' | null
    recallFeedback: plan.recallFeedback ?? null,
    recallFeedbackNote: plan.recallFeedbackNote ?? null,
    /// 批次意图:这单是主管点哪一格分下来的;null=自助认领或批次已撤销(回落"分最高")
    focusLabel: plan.focusLabel ?? null,
    reasons: plan.reasons.map((r) => ({
      id: r.id,
      scenario: r.scenario,
      subKey: r.subKey,
      priorityScore: r.priorityScore,
      reason: r.reason,
      lifecycle: r.lifecycle,
      source: r.source,
      /// W3 末:evidence 改 JSON 统一(跟 PersonaFeature.evidence 对齐),透 JSON 整体
      evidence: r.evidence,
      /// v2.1:6 因子算法 breakdown + subKey,UI 详情页"为什么 73 分"渲染数据来源
      /// (不含 ruleIds — 算法版本追溯靠 git + plan.createdAt)
      breakdown: r.breakdown,
      /// W3 末:结构化召回信号(raw enum / canonical code,前端字典翻译富文本)
      /// daysSince 用锚点 signalOccurredAt 实时重算 → 跟治疗链断口(同请求 server 时刻)精确一致,
      /// 不再随天数陈旧("388/389"漂移);旧 plan 无锚点 → 原样回落 baked。
      signals: applyLiveDays(
        r.signals as { daysSince?: number; signalOccurredAt?: string | null } | null,
      ),
    })),
  };
}

function serializePersona(persona: {
  id: string;
  version: number;
  computedAt: Date;
  refreshedAt: Date | null;
  features: Array<{
    id: string;
    key: string;
    description: string;
    score: number | null;
    data: unknown;
    evidence: unknown;
  }>;
}) {
  return {
    id: persona.id,
    version: persona.version,
    computedAt: persona.computedAt.toISOString(),
    /// 内容真实新鲜度:就地刷新过就报刷新时刻(computedAt 是版本创建时刻,覆盖率日报靠它反推快照)
    refreshedAt: (persona.refreshedAt ?? persona.computedAt).toISOString(),
    features: persona.features.map((f) => ({
      id: f.id,
      key: f.key,
      description: f.description,
      score: f.score,
      /// 结构化 payload(如 entitlement_status 的 {commercialInsured, commercialInsurers, medicalInsured...})
      data: f.data ?? null,
      /// 证据 fact id —— 详情页「依据」区反查同一响应里的 facts 渲染。
      /// 之前这里没透出,前端 evidence 恒为空数组:库里存着的证据链(rfm/lifecycle 两个
      /// 特征就占 1 GB)一条都没被用上,客服只能看到结论看不到出处。
      evidenceFactIds: extractFactIds(f.evidence),
    })),
  };
}

/// evidence 形状由各 extractor 自管({factIds, agentInvocationIds?}),这里只取 factIds 且防脏
function extractFactIds(evidence: unknown): string[] {
  if (!evidence || typeof evidence !== 'object') return [];
  const ids = (evidence as { factIds?: unknown }).factIds;
  return Array.isArray(ids) ? ids.filter((x): x is string => typeof x === 'string') : [];
}

/**
 * W4 新加:PlanScript serializer + markdown → sections 反 parse。
 *
 * 后端写库时 plan-script.orchestrator.renderMarkdown 把 4 段拼成:
 *   > 患者:xxx · 语气:xxx
 *   ## 开场白\n{opening}\n## 告知潜在治疗\n{informMissed}\n## 复查建议\n{reviewAdvice}\n## 结束回访语\n{closing}
 * 这里反向用 regex 按 H2 标题切回 4 段,前端拿到的 sections shape 跟 mockScript 完全一致。
 *
 * 设计决策:**前端单一消费 sections 接口**(mock / 真实 / 流式三路同 shape),
 * 反 parse 集中在后端做,避免前端多套渲染分支。
 */
function serializeScript(s: {
  id: string;
  content: string | null;
  source: string | null;
  status: string;
  generatedAt?: Date | null;
  updatedAt: Date;
}) {
  const sections = parseScriptMarkdownToSections(s.content ?? '');
  return {
    id: s.id,
    status: s.status,
    source: (s.source as 'agent' | 'template_fallback' | 'manual' | null) ?? 'agent',
    generatedAt: (s.generatedAt ?? s.updatedAt).toISOString(),
    sections,
  };
}

/**
 * markdown H2 标题 → 段 id 的**解析键**。
 *
 * ⚠️ 这里必须保持「告知潜在治疗」,别跟下面 SECTION_META 的展示名一起改成「告知潜在治疗」——
 *   它匹配的是**已经落库的话术正文**(plan_scripts.markdown 里 AI 写下的 `## 告知潜在治疗`),
 *   以及 prompt 当前仍在输出的标题。改这里 = 存量话术全部解析不出 informMissed 段,
 *   打开就是空白。展示名换词只需改 SECTION_META,解析键跟着 prompt 走。
 *   哪天真要改 prompt 输出的标题,这里得**同时认新旧两个键**再切。
 */
const SECTION_HEAD_TO_ID: Record<string, 'opening' | 'informMissed' | 'reviewAdvice' | 'closing'> = {
  开场白: 'opening',
  告知潜在治疗: 'informMissed',
  复查建议: 'reviewAdvice',
  结束回访语: 'closing',
};
const SECTION_META: Record<
  'opening' | 'informMissed' | 'reviewAdvice' | 'closing',
  { label: string; durationHint: string }
> = {
  opening: { label: '开场白', durationHint: '' },
  informMissed: { label: '告知潜在治疗', durationHint: '' },
  reviewAdvice: { label: '复查建议', durationHint: '' },
  closing: { label: '结束回访语', durationHint: '' },
};

function parseScriptMarkdownToSections(md: string) {
  // ⭐ 通用 H2 切分:每个 `## 标题` 起一段,到下一个 H2 之间为内容。
  //   - 稳健档:4 个固定标题(开场白/告知潜在治疗/复查建议/结束回访语)→ 映射到已知 id + 固定 label。
  //   - 标准/深度档:自由标题(段数不定)→ id=`s{序号}`、label=原标题。
  //   旧实现只认稳健 4 固定标题 → 深度/标准的自由标题全 currentId=null、内容丢弃 → 刷新后空白
  //   (plan_scripts 存了内容,但反 parse 解不出 → 前端"尚未生成参考话术")。本版按任意 H2 解析,三档通用。
  //   H2 之前的前言(`> 患者:… · 语气:…`)在首个 H2 前,自然跳过。
  if (!md) return [];
  const lines = md.split('\n');
  const sections: Array<{ id: string; label: string; durationHint: string; markdown: string }> = [];
  let cur: { id: string; label: string; durationHint: string; buf: string[] } | null = null;
  let idx = 0;
  const flush = () => {
    if (cur) sections.push({ id: cur.id, label: cur.label, durationHint: cur.durationHint, markdown: cur.buf.join('\n').trim() });
  };
  for (const line of lines) {
    const m = /^##\s+(.+?)\s*$/.exec(line);
    if (m) {
      flush();
      const head = (m[1] ?? '').trim();
      const fixedId = SECTION_HEAD_TO_ID[head];
      cur = fixedId
        ? { id: fixedId, label: SECTION_META[fixedId].label, durationHint: SECTION_META[fixedId].durationHint, buf: [] }
        : { id: `s${idx}`, label: head, durationHint: '', buf: [] };
      idx++;
      continue;
    }
    if (cur) cur.buf.push(line);
  }
  flush();
  return sections;
}

function serializeFact(f: {
  id: string;
  type: string;
  kind: string;
  status: string;
  occurredAt: Date | null;
  plannedFor: Date | null;
  clinicId: string | null;
  title: string | null;
  summary: string | null;
  content: Prisma.JsonValue;
}) {
  return {
    id: f.id,
    type: f.type,
    kind: f.kind,
    status: f.status,
    occurredAt: f.occurredAt?.toISOString() ?? null,
    // planned 类(预约/计划治疗)时间在 plannedFor;透传给前端时间轴排序兜底
    plannedFor: f.plannedFor?.toISOString() ?? null,
    clinicId: f.clinicId,
    title: f.title,
    summary: f.summary,
    content: f.content,
  };
}

// ─────────────────────────────────────────────
// 小工具
// ─────────────────────────────────────────────
/// payment/recharge/refund.content 实际字段是 `amount_cents`(parser 写库时归一为 cents int);
/// 老代码读 `c.amount` 是字段名错配,导致 LTV 永远 0(实测路遥 ¥58 显示成 0)。W3 末修正。
function sumAmount(facts: Array<{ content: Prisma.JsonValue }>): number {
  let total = 0;
  for (const f of facts) {
    const c = f.content as Record<string, unknown>;
    total += Number(c?.amount_cents ?? c?.amount ?? 0);
  }
  return total;
}
