import { Injectable, Logger, NotFoundException } from '@nestjs/common';
import { randomUUID } from 'node:crypto';

/**
 * `patient_return_visits.result` 里的**零信息**取值 —— 视同没有结果。
 * ⛔ 别把它们当结果说出去:「上次回访结果:无」对客服没有任何用,还占字数。
 */
const NOISE_RESULTS = new Set(['无', '无内容', '未填写', '-', '—']);
import { maskName } from '@pac/utils';
import {
  PERSONA_FEATURE_SPECS,
  subLabelZh,
  diagnosisCodeNameZh,
  treatmentCategoryNameZh,
} from '@pac/types';
import { PrismaService } from '../../../prisma/prisma.service';
import { AiCallRunnerService } from '../ai-call-runner.service';
import { readBenefitText } from '../../plan/plan-assignment.service';
import { DraftRecallBriefCall } from '../calls/draft-recall-brief/call';
import type {
  DraftRecallBriefInput,
  DraftRecallBriefOutput,
} from '../calls/draft-recall-brief/input.types';
import type { TenantScopeContext } from '../../../common/decorators/tenant-scope.decorator';

export interface RecallBriefResult {
  /** 四点召回简报;status='empty' 时为 null */
  summary: DraftRecallBriefOutput | null;
  status: 'ready' | 'empty';
  source?: 'agent' | 'template_fallback';
}

/**
 * 落库形态兼容 —— `plan_summaries.content` 是**单个字符串列**。
 * 新版存 JSON(四段);⚠️ 库里还有大量**老行是纯文本一句话**,直接 JSON.parse 会抛。
 * 老行整句落到 `problem`(它承载的正是原来那句的主干),其余三段给"—",
 * ⛔ 不要丢弃老行:那会让每个存量 plan 打开时白跑一次 LLM。
 */
function parseBriefContent(content: string): DraftRecallBriefOutput {
  try {
    const o = JSON.parse(content) as Partial<DraftRecallBriefOutput>;
    if (o && typeof o.problem === 'string') {
      return {
        who: o.who ?? '—',
        history: o.history ?? '—',
        problem: o.problem,
        hook: o.hook ?? '—',
      };
    }
  } catch {
    // 不是 JSON → 老行,走下面的兜底
  }
  return { who: '—', history: '—', problem: content, hook: '—' };
}

/** plan_reasons.signals 形态(跟前端 ReasonLine 契约一致)。 */
interface ReasonSignals {
  subKey?: string | null;
  triggers?: Array<{ type?: string; code?: string | null }>;
  toothPosition?: string | null;
  daysSince?: number;
  expectedCategories?: string[];
}

/**
 * 把天数转人读文本(server 端轻量版,无需精确到日)。
 *
 * 🔴 **向下取整,⛔ 不许用 Math.round**(2026-08-06 实测)。
 *   原来是 `Math.round(d / 30)`:74 天 → 「约 2 个月」,75 天 → 「约 3 个月」——
 *   **相差一天,跨了整整一个月**。同一个患者相邻两天的两条回访因此被写成
 *   「2 个月前…、3 个月前…」,读起来像隔了一个月发生的两件事(实测 刘吉琳)。
 *   四舍五入还会**高估**拖延时长(75 天说成 3 个月,虚报 20%),与"不夸大"相悖。
 * ⚠️ 用 30.44(平均月长)而不是 30:按 30 算 365 天会得出 12 个月,与「1 年」重复。
 * ⚠️ 月/年分档按**天数**切(d < 365),⛔ 别按算出来的月数切 —— floor(365/30.44)=11,
 *   写成 `months < 12` 会让整整一年被说成「约 11 个月」。
 * ⚠️ 任何分档都有边界,但向下取整至少保证**单调**且**不虚报**;
 *   精确天数本来就一并给了模型(「74 天(约 2 个月)」),要区分相邻两条时它有得用。
 */
export function daysText(d: number): string {
  if (!Number.isFinite(d) || d <= 0) return '近期';
  if (d < 60) return `${d} 天`;
  // ⚠️ 分档按**天数**切,⛔ 别按算出来的月数切:floor(365/30.44)=11,
  //   写成 `months < 12` 会让整整一年被说成「约 11 个月」,年档永远够不着。
  if (d < 365) return `${d} 天(约 ${Math.floor(d / 30.44)} 个月)`;
  const years = Math.floor((d / 365) * 10) / 10;
  return `${d} 天(约 ${years} 年)`;
}

/**
 * RecallBriefOrchestrator — 本次召回「一句话简报」get-or-generate。
 *
 *   有 ready 的 plan_summaries[recall_brief] → 直接返回(秒回,不调 LLM)。
 *   没有 → 取 召回原因(plan_reasons) + 历史治疗(treatment_record actual 计数) + 画像(persona_features)
 *          → 跑 AiCall(回答 谁/解决什么/到诊做什么 三问)→ upsert 存库 → 返回。
 *   无召回原因 → status='empty'。
 *
 * 与 Recall/PersonaSummaryOrchestrator 平行,只是输入更全(三类信号合一)。
 */
@Injectable()
export class RecallBriefOrchestrator {
  private readonly logger = new Logger(RecallBriefOrchestrator.name);
  private static readonly TYPE = 'recall_brief' as const;

  constructor(
    private readonly prisma: PrismaService,
    private readonly runner: AiCallRunnerService,
    private readonly call: DraftRecallBriefCall,
  ) {}

  async getOrGenerate(scope: TenantScopeContext, planId: string): Promise<RecallBriefResult> {
    const plan = await this.prisma.followupPlan.findUnique({
      where: { id: planId },
      select: {
        id: true,
        hostId: true,
        tenantId: true,
        patientId: true,
        reasons: {
          orderBy: { priorityScore: 'desc' },
          select: { scenario: true, subKey: true, reason: true, signals: true },
        },
        // 本批福利(第④点最高优先);判据同话术:只认 confirmed 的批次
        assignment: { select: { status: true, attributes: true } },
      },
    });
    if (!plan || plan.hostId !== scope.hostId || plan.tenantId !== scope.tenantId) {
      throw new NotFoundException(`Plan ${planId} not found`);
    }
    if (plan.reasons.length === 0) return { summary: null, status: 'empty' };

    // 1) 已生成 → 直接返回
    const existing = await this.prisma.planSummary.findUnique({
      where: { planId_type: { planId: plan.id, type: RecallBriefOrchestrator.TYPE } },
      select: { content: true, status: true, source: true },
    });
    if (existing?.status === 'ready' && existing.content) {
      return {
        summary: parseBriefContent(existing.content),
        status: 'ready',
        source: (existing.source as 'agent' | 'template_fallback' | null) ?? undefined,
      };
    }

    // 2) 召回原因(翻译成中文)
    const reasons = plan.reasons.map((r) => {
      const s = (r.signals ?? {}) as ReasonSignals;
      const code = (s.triggers ?? []).find((t) => /^K\d/i.test(t.code ?? ''))?.code ?? null;
      // 触发感知(同 ReasonLine/话术):lead=最高优先来源(诊断>建议>影像)。
      //   lead=建议(无诊断)→ subLabel 用建议名("建议种植"),不套子场景诊断词"缺失牙未启动修复"。
      const lead = (s.triggers ?? [])[0];
      const isRecLead = lead?.type === 'recommendation' && !!lead.code;
      const source: 'diagnosis' | 'recommendation' | 'image_finding' =
        lead?.type === 'recommendation'
          ? 'recommendation'
          : lead?.type === 'image_finding'
            ? 'image_finding'
            : 'diagnosis';
      return {
        subLabel: isRecLead
          ? diagnosisCodeNameZh(lead.code as string)
          : subLabelZh(r.scenario, s.subKey ?? '') || r.scenario,
        source,
        diagnosis: code ? diagnosisCodeNameZh(code) : null,
        tooth: (s.toothPosition ?? '').trim() || null,
        daysSinceText: daysText(s.daysSince ?? 0),
        expectedCategories: (s.expectedCategories ?? []).map((c) => treatmentCategoryNameZh(c)),
      };
    });

    // 3) 历史治疗:treatment_record actual 按 category 计数
    const txFacts = await this.prisma.patientFact.findMany({
      where: {
        hostId: scope.hostId,
        tenantId: scope.tenantId,
        patientId: plan.patientId,
        type: 'treatment_record',
        kind: 'actual',
      },
      select: { content: true, occurredAt: true },
    });
    const catCount = new Map<string, number>();
    // 复查切入锚点(第④问事实依据):最近一次实际治疗 + 最近一次洁牙/检查(preventive/periodontic)
    let lastVisitAt: Date | null = null;
    let lastCleaningAt: Date | null = null;
    const CLEANING_CATEGORIES = new Set(['preventive', 'periodontic']);
    for (const f of txFacts) {
      const cat = String((f.content as Record<string, unknown> | null)?.category ?? '').trim();
      if (!cat) continue;
      catCount.set(cat, (catCount.get(cat) ?? 0) + 1);
      if (f.occurredAt) {
        if (!lastVisitAt || f.occurredAt > lastVisitAt) lastVisitAt = f.occurredAt;
        if (CLEANING_CATEGORIES.has(cat) && (!lastCleaningAt || f.occurredAt > lastCleaningAt)) {
          lastCleaningAt = f.occurredAt;
        }
      }
    }
    const now = Date.now();
    const anchorText = (d: Date | null): string | null => {
      if (!d) return null;
      const days = Math.floor((now - d.getTime()) / 86_400_000);
      return days <= 0 ? '近期' : `${daysText(days)}前`;
    };
    const reviewAnchors = {
      lastVisitText: anchorText(lastVisitAt),
      lastCleaningText: anchorText(lastCleaningAt),
    };

    // 3.5) 医生计划/建议/医嘱(第④问切入的最优先事实依据,统一带 verbatim 原话):
    //   治疗计划 = treatment_record kind='planned' / 医生建议 = recommendation_record /
    //   医嘱 = emr_record.content.doctor_advice(真实医嘱文本;"常嘱"级短水词丢弃)
    const clip = (s: string, max: number): string => (s.length > max ? `${s.slice(0, max)}…` : s);
    const [plannedFacts, recFacts, emrFacts] = await Promise.all([
      this.prisma.patientFact.findMany({
        where: {
          hostId: scope.hostId,
          tenantId: scope.tenantId,
          patientId: plan.patientId,
          type: 'treatment_record',
          kind: 'planned',
        },
        orderBy: { occurredAt: 'desc' },
        take: 3,
        select: { content: true, occurredAt: true },
      }),
      this.prisma.patientFact.findMany({
        where: {
          hostId: scope.hostId,
          tenantId: scope.tenantId,
          patientId: plan.patientId,
          type: 'recommendation_record',
        },
        orderBy: { occurredAt: 'desc' },
        take: 3,
        select: { content: true, occurredAt: true },
      }),
      this.prisma.patientFact.findMany({
        where: {
          hostId: scope.hostId,
          tenantId: scope.tenantId,
          patientId: plan.patientId,
          type: 'emr_record',
        },
        orderBy: { occurredAt: 'desc' },
        take: 5,
        select: { content: true, occurredAt: true },
      }),
    ]);
    const doctorGuidance: DraftRecallBriefInput['doctorGuidance'] = [
      ...plannedFacts.map((f) => {
        const c = (f.content ?? {}) as Record<string, unknown>;
        const raw = String(c.subtype ?? '').trim();
        return {
          kind: 'plan' as const,
          label: treatmentCategoryNameZh(String(c.category ?? '')),
          verbatim: raw ? clip(raw, 40) : null,
          tooth: String(c.tooth_position ?? '').trim() || null,
          atText: anchorText(f.occurredAt),
        };
      }),
      ...recFacts.map((f) => {
        const c = (f.content ?? {}) as Record<string, unknown>;
        const code = String(c.code ?? '').trim();
        const name = String(c.name ?? '').trim();
        return {
          kind: 'recommendation' as const,
          label: code ? diagnosisCodeNameZh(code) : '医生建议',
          verbatim: name ? clip(name, 40) : null,
          tooth: String(c.tooth_position ?? '').trim() || null,
          atText: anchorText(f.occurredAt),
        };
      }),
      // 医嘱:只收有信息量的(≥6 字过滤"常嘱"级水词),最近 2 条
      ...emrFacts
        .map((f) => ({
          advice: String(((f.content ?? {}) as Record<string, unknown>).doctor_advice ?? '').trim(),
          occurredAt: f.occurredAt,
        }))
        .filter((x) => x.advice.length >= 6)
        .slice(0, 2)
        .map((x) => ({
          kind: 'advice' as const,
          label: '医嘱',
          verbatim: clip(x.advice, 40),
          tooth: null,
          atText: anchorText(x.occurredAt),
        })),
    ];
    const treatmentHistory = [...catCount.entries()]
      .sort((a, b) => b[1] - a[1])
      .map(([cat, count]) => ({ category: treatmentCategoryNameZh(cat), count }));

    // 4) 画像标签
    const persona = await this.prisma.persona.findFirst({
      where: { patientId: plan.patientId, supersededAt: null },
      orderBy: { version: 'desc' },
      select: { features: { orderBy: { createdAt: 'asc' }, select: { key: true, description: true } } },
    });
    const personaItems = (persona?.features ?? []).map((f) => ({
      label: PERSONA_FEATURE_SPECS[f.key]?.nameZh ?? f.key,
      value: f.description,
    }));

    const patient = await this.prisma.patient.findUnique({
      where: { id: plan.patientId },
      select: { name: true },
    });

    // 4b) 回访历史(第②点)+ 本批福利(第④点最高优先)
    const visits = await this.prisma.patientReturnVisit.findMany({
      where: { hostId: plan.hostId, tenantId: plan.tenantId, patientId: plan.patientId },
      orderBy: { taskDate: { sort: 'desc', nulls: 'last' } },
      take: 3,
      // ⛔ 不取 taskStatus:它是内部流程状态,刻意不喂给模型(理由见下面 reachedText 的注释)
      select: { taskDate: true, type: true, status: true, result: true, followContent: true },
    });
    const nowMs = Date.now();
    const returnVisitHistory = visits
      // ⛔ 只要**已发生**的:未来的预约回访说成历史就是编(见 input.types 的注释)
      .filter((v) => v.taskDate != null && v.taskDate.getTime() <= nowMs)
      .map((v) => ({
        atText: `${daysText(Math.floor((nowMs - v.taskDate!.getTime()) / 86_400_000))}前`,
        type: v.type?.trim() || null,
        /**
         * ⭐ **这次回访是干什么的**(follow_content)—— 主题:半年洁牙提醒 / 种植潜客 / 术后复查…
         */
        topicText: v.followContent?.trim().slice(0, 40) || null,
        /**
         * ⭐⭐ **结果怎么样**(result)—— 未接 / 诊后回访无不适 / 约时间 / 复查…
         *
         * 🔴 由来(2026-08-06 实测):这个字段**一开始漏喂了**,于是没有内容的那条回访
         *   只能输出「没留下结果」——而库里明明写着「诊后回访未接」,详情页「历史联系」卡
         *   也据此说了「未接通」。两处一对比,简报显得又含糊又像少了信息。
         * ⚠️ `result` 比 `status` **可信**:实测同一条 status='已回访' 而 result='诊后回访未接'
         *   —— 前者是任务被标记过,后者才是真的联系结果。⇒ 有 result 就以 result 为准。
         * ⛔ 「无」「无内容」这类零信息词视同没有,别当成结果说出去。
         */
        resultText: NOISE_RESULTS.has(v.result?.trim() ?? '')
          ? null
          : v.result?.trim().slice(0, 40) || null,
        /**
         * 联系上没有(已回访 / 未回访)—— **只在结果和主题都为空时**当兜底。
         * ⚠️ 它并不可靠(见上面 resultText:已回访 + 未接 同时出现过),所以排在最后。
         *
         * ⛔⛔ **`taskStatus`(已完成 / 未完成 / 已预约)刻意不给**,别"补全"回来:
         *   ① 那是**内部流程状态**,客服打电话前根本用不上(产品 2026-08-06 走查:
         *      「回访任务已完成就不要说了,总结备注里的事实就行」);
         *   ② 它还会和详情页「历史联系」卡打架 —— 实测(2026-08-05 王利)喂了两个状态后,
         *      摘要说「已联系且任务已完成」、那张卡说「未完成」,两句都对却像自相矛盾。
         *   不报流程状态,这个矛盾从根上就不存在了。
         */
        reachedText: v.status?.trim() || null,
      }));

    /**
     * 本批福利 —— ⚠️ 只在批次仍 `confirmed` 时给。
     * 与话术带不带福利**同一个判据**(plan-script.orchestrator 的 benefit),⛔ 别另立标准:
     * 撤销后的批次福利已不适用,带进去就是让客服对着患者念一个作废的优惠。
     */
    const benefitText =
      plan.assignment?.status === 'confirmed' ? readBenefitText(plan.assignment.attributes) : null;

    /**
     * 🔴 **关系亲密度**(第②句的另一半)—— 产品 2026-08-04:「联系他多不多、有没有过不好的体验」。
     *
     * ⚠️ 必须**单独聚合**,⛔ 不能从上面那 3 条 `visits` 里数:
     *   那个查询 `take: 3`(喂全量会撑爆上下文),拿它算总量必然低估 ——
     *   实测有 1131 位患者被联系过 10 次以上,只看 3 条会一律说成"联系过 3 次"。
     * ⚠️ 与 returnVisitHistory **同一条判据**:只算**已发生**的(未来的预约回访不是"联系过")。
     * ⭐ 一次 SQL 出两个数,⛔ 别拆成两次查询 —— 拆开必然有一天两个口径漂移。
     */
    const [stats] = await this.prisma.$queryRaw<
      Array<{ total: bigint; since: Date | null; hangups: bigint }>
    >`
      SELECT count(*)                                        AS total,
             min(task_date)                                  AS since,
             count(*) FILTER (WHERE result LIKE '%挂断%')     AS hangups
      FROM patient_return_visits
      WHERE host_id = ${plan.hostId}::uuid
        AND tenant_id = ${plan.tenantId}
        AND patient_id = ${plan.patientId}::uuid
        AND task_date IS NOT NULL
        AND task_date <= now()
    `;
    const totalContacts = Number(stats?.total ?? 0);
    const contactStats = {
      total: totalContacts,
      sinceText:
        totalContacts > 0 && stats?.since
          ? `${daysText(Math.floor((nowMs - stats.since.getTime()) / 86_400_000))}前`
          : null,
    };

    const input: DraftRecallBriefInput = {
      patientNameMasked: maskName(patient?.name ?? null) ?? '该患者',
      persona: personaItems,
      treatmentHistory,
      reasons,
      reviewAnchors,
      doctorGuidance,
      returnVisitHistory,
      contactStats,
      hangUpCount: Number(stats?.hangups ?? 0),
      benefitText,
    };

    // 5) 跑 AiCall → upsert
    const result = await this.runner.run(this.call, input, {
      hostId: plan.hostId,
      tenantId: plan.tenantId,
      workflowRunId: randomUUID(),
      linkedPatientId: plan.patientId,
      linkedPlanId: plan.id,
      bustCache: false,
      evalMode: 'production',
    });

    await this.prisma.planSummary.upsert({
      where: { planId_type: { planId: plan.id, type: RecallBriefOrchestrator.TYPE } },
      create: {
        hostId: plan.hostId,
        tenantId: plan.tenantId,
        planId: plan.id,
        type: RecallBriefOrchestrator.TYPE,
        content: JSON.stringify(result.output),
        status: 'ready',
        source: result.source,
        agentInvocationId: result.invocationId,
      },
      update: {
        content: JSON.stringify(result.output),
        status: 'ready',
        source: result.source,
        agentInvocationId: result.invocationId,
      },
    });

    return { summary: result.output, status: 'ready', source: result.source };
  }
}
