import { Injectable, Logger, NotFoundException } from '@nestjs/common';
import { randomUUID } from 'node:crypto';
import type { Prisma } from '@prisma/client';
import { fmtYearMonth } from '@pac/utils';
import { planScenarioLabel, personaFeatureMeta } from '@pac/types';
import { PrismaService } from '../../../prisma/prisma.service';
import { AiCallRunnerService } from '../ai-call-runner.service';
import { DraftPlanScriptCall } from '../calls/draft-plan-script/call';
import type {
  DraftPlanScriptInput,
  DraftPlanScriptOutput,
} from '../calls/draft-plan-script/input.types';

/**
 * 流式事件 — 给前端 SSE 用。
 * 跟 AiCallRunner.StreamEvent 平行但多了渲染好的 content/sections,前端零计算直接渲染。
 */
export type PlanScriptStreamEvent =
  | { type: 'start'; invocationId: string; modelId: string; promptVersion: string }
  | {
      type: 'partial';
      structured: Partial<DraftPlanScriptOutput>;
      sections: ScriptSectionDto[];
    }
  | {
      type: 'done';
      planScriptId: string | null;
      agentInvocationId: string;
      source: 'agent' | 'template_fallback';
      structured: DraftPlanScriptOutput;
      content: string;
      sections: ScriptSectionDto[];
      costYuan: number;
      promptTokens: number;
      completionTokens: number;
      fallbackReason?: string;
    }
  | { type: 'error'; message: string };

/**
 * 渲染后的 section,前端直接消费(B 方案:4 段对齐前端 mockScript)。
 *   - opening   开场
 *   - followup  切入话题
 *   - objection 异议处理
 *   - close     结束·信息确认
 * 跟 apps/pac-web/.../mock-data.ts mockScript.sections id 一致,前端零改动消费。
 */
export interface ScriptSectionDto {
  id: 'opening' | 'followup' | 'objection' | 'close';
  label: string;
  durationHint: string;
  markdown: string;
}

/**
 * PlanScriptOrchestrator — 业务侧编排:planId → AI 话术 → 写 PlanScript 表。
 *
 * 与 AiCallRunner 的分工:
 *   - Runner 是 harness 基建,只关心"跑一次 AI 调用"
 *   - Orchestrator 是业务,关心"从 DB 装 input、决定调哪个 AiCall、把 output 渲染成 Markdown 写库"
 *
 * 这一层属于"业务编排",不直接 import @ai-sdk;AiCall / Runner 是 SDK 边界。
 */

export interface PlanScriptGenerateOptions {
  /** 强制跳过缓存(重新生成场景) */
  bustCache?: boolean;
  /** eval / 测试场景下覆盖模型 */
  modelIdOverride?: string;
  /** 测试模式:不写 PlanScript 表(只看输出) */
  dryRun?: boolean;
}

export interface PlanScriptGenerateResult {
  planId: string;
  planScriptId: string | null;        // dryRun 时为 null
  agentInvocationId: string;
  source: 'agent' | 'template_fallback';
  cacheHit: boolean;
  costYuan: number;
  content: string;                     // 渲染后的 Markdown
  structured: DraftPlanScriptOutput;   // 结构化原始输出(给调试 / 前端做高级展示)
}

@Injectable()
export class PlanScriptOrchestrator {
  private readonly logger = new Logger(PlanScriptOrchestrator.name);

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

  /**
   * 生成 / 重新生成 plan 话术。
   * - 第一次生成:写 PlanScript 行
   * - 重新生成:upsert(UNIQUE planId)— 内容覆盖,agentInvocationId 指向新调用
   *
   * 注意:本方法**不重新走患者数据 pull**,直接从 DB 读现有 plan/persona/facts。
   * 这是"测试场景下重跑话术"的标准路径 — 输入完全确定,只看 prompt/model 改动效果。
   */
  async generate(
    planId: string,
    options: PlanScriptGenerateOptions = {},
  ): Promise<PlanScriptGenerateResult> {
    // ─── 1. 装配 input(纯 DB 读,不 pull) ───
    const { plan, patient, persona, facts } = await this.loadPlanContext(planId);
    const input = this.buildCallInput({ plan, patient, persona, facts });

    // ─── 2. 跑 AI 调用 ───
    const workflowRunId = randomUUID();
    const result = await this.runner.run(this.call, input, {
      hostId: plan.hostId,
      tenantId: plan.tenantId,
      workflowRunId,
      linkedPatientId: patient.id,
      linkedPersonaId: persona?.id,
      linkedPlanId: plan.id,
      bustCache: options.bustCache ?? false,
      modelIdOverride: options.modelIdOverride,
      evalMode: 'production',
    });

    // ─── 3. 渲染 Markdown ───
    const content = renderMarkdown(result.output, { patientNameMasked: input.patient.nameMasked });

    // ─── 4. 写 PlanScript(dryRun 时跳过) ───
    let planScriptId: string | null = null;
    if (!options.dryRun) {
      const row = await this.prisma.planScript.upsert({
        where: { planId: plan.id },
        create: {
          hostId: plan.hostId,
          tenantId: plan.tenantId,
          planId: plan.id,
          content,
          status: 'ready',
          source: result.source,
          agentInvocationId: result.invocationId,
        },
        update: {
          content,
          status: 'ready',
          source: result.source,
          agentInvocationId: result.invocationId,
        },
        select: { id: true },
      });
      planScriptId = row.id;
    }

    return {
      planId: plan.id,
      planScriptId,
      agentInvocationId: result.invocationId,
      source: result.source,
      cacheHit: result.cacheHit,
      costYuan: result.costYuan,
      content,
      structured: result.output,
    };
  }

  /**
   * 流式版本 — 返回 AsyncGenerator,逐步 yield 渲染好的 sections。
   *
   * 调用方(controller)把每个事件转换成 SSE 行吐给浏览器。
   * 完成时仍然写 PlanScript 表(无论 source=agent 还是 template_fallback)。
   */
  async *generateStream(
    planId: string,
    options: PlanScriptGenerateOptions = {},
  ): AsyncGenerator<PlanScriptStreamEvent, void, void> {
    let plan: PlanWithReasons;
    let patient: PatientRow;
    let persona: PersonaWithFeatures | null;
    let input: DraftPlanScriptInput;
    try {
      const ctx = await this.loadPlanContext(planId);
      plan = ctx.plan;
      patient = ctx.patient;
      persona = ctx.persona;
      input = this.buildCallInput(ctx);
    } catch (err) {
      yield { type: 'error', message: err instanceof Error ? err.message : String(err) };
      return;
    }

    const workflowRunId = randomUUID();
    const events = this.runner.stream(this.call, input, {
      hostId: plan.hostId,
      tenantId: plan.tenantId,
      workflowRunId,
      linkedPatientId: patient.id,
      linkedPersonaId: persona?.id,
      linkedPlanId: plan.id,
      bustCache: true,             // 流式强制 bust(命中缓存的话没流可流)
      modelIdOverride: options.modelIdOverride,
      evalMode: 'production',
    });

    const patientNameMasked = input.patient.nameMasked;
    let lastStructured: DraftPlanScriptOutput | null = null;
    let lastInvocationId: string | null = null;
    let lastSource: 'agent' | 'template_fallback' = 'agent';
    let lastCost = 0;
    let lastPromptTokens = 0;
    let lastCompletionTokens = 0;
    let lastFallbackReason: string | undefined;

    for await (const evt of events) {
      if (evt.type === 'start') {
        lastInvocationId = evt.invocationId;
        yield { type: 'start', invocationId: evt.invocationId, modelId: evt.modelId, promptVersion: evt.promptVersion };
      } else if (evt.type === 'partial') {
        yield {
          type: 'partial',
          structured: evt.partial,
          sections: renderSections(evt.partial, { patientNameMasked }),
        };
      } else if (evt.type === 'done') {
        lastStructured = evt.output;
        lastSource = evt.source;
        lastCost = evt.costYuan;
        lastPromptTokens = evt.promptTokens;
        lastCompletionTokens = evt.completionTokens;
        lastFallbackReason = evt.fallbackReason;
      } else if (evt.type === 'error') {
        yield { type: 'error', message: evt.message };
        return;
      }
    }

    if (!lastStructured) {
      yield { type: 'error', message: '生成结束但未拿到最终对象' };
      return;
    }

    // 写库(dryRun 跳过)
    const content = renderMarkdown(lastStructured, { patientNameMasked });
    let planScriptId: string | null = null;
    if (!options.dryRun) {
      const row = await this.prisma.planScript.upsert({
        where: { planId: plan.id },
        create: {
          hostId: plan.hostId,
          tenantId: plan.tenantId,
          planId: plan.id,
          content,
          status: 'ready',
          source: lastSource,
          agentInvocationId: lastInvocationId,
        },
        update: {
          content,
          status: 'ready',
          source: lastSource,
          agentInvocationId: lastInvocationId,
        },
        select: { id: true },
      });
      planScriptId = row.id;
    }

    yield {
      type: 'done',
      planScriptId,
      agentInvocationId: lastInvocationId ?? '',
      source: lastSource,
      structured: lastStructured,
      content,
      sections: renderSections(lastStructured, { patientNameMasked }),
      costYuan: lastCost,
      promptTokens: lastPromptTokens,
      completionTokens: lastCompletionTokens,
      fallbackReason: lastFallbackReason,
    };
  }

  // ─────────────────────────────────────────────
  // input 装配
  // ─────────────────────────────────────────────

  private async loadPlanContext(planId: string) {
    const plan = await this.prisma.followupPlan.findUnique({
      where: { id: planId },
      include: { reasons: { orderBy: { priorityScore: 'desc' }, take: 3 } },
    });
    if (!plan) throw new NotFoundException(`Plan ${planId} not found`);

    const patient = await this.prisma.patient.findUnique({
      where: { id: plan.patientId },
    });
    if (!patient) throw new NotFoundException(`Patient ${plan.patientId} not found`);

    const persona = await this.prisma.persona.findFirst({
      where: { patientId: patient.id, supersededAt: null },
      include: { features: true },
      orderBy: { version: 'desc' },
    });

    const facts = await this.prisma.patientFact.findMany({
      where: {
        hostId: plan.hostId,
        tenantId: plan.tenantId,
        patientId: patient.id,
        status: 'active',
      },
      orderBy: { occurredAt: 'desc' },
    });

    return { plan, patient, persona, facts };
  }

  private buildCallInput(args: {
    plan: PlanWithReasons;
    patient: PatientRow;
    persona: PersonaWithFeatures | null;
    facts: FactRow[];
  }): DraftPlanScriptInput {
    const { plan, patient, persona, facts } = args;

    const encounters = facts.filter((f) => f.type === 'encounter_record');
    const latestEnc = encounters[0];
    const daysSinceLastVisit = latestEnc?.occurredAt
      ? Math.floor((Date.now() - latestEnc.occurredAt.getTime()) / 86400_000)
      : null;

    return {
      patient: {
        // 注意:LLM 用的 nameMasked 是"路先生/女士"通话称呼,不是脱敏掩码"路*"
        // (后者是 UI 列表展示用,客服通话不能直接念"路星")
        nameMasked: nameSpokenForm(patient.name, patient.gender),
        gender: patient.gender,
        age: patient.birthDate ? calcAge(patient.birthDate) : null,
      },
      // 临时:hardcoded jvs-dw 诊所字典(TODO #56 接 host 字典或新建 clinics 表)
      // ⚠️ 直接吐 UUID 进 prompt 会让 LLM 编造"XX 客服中心",必须翻译成中文名
      clinicName: resolveClinicName(plan.targetClinicId),
      plan: {
        primaryScenarioLabel: plan.reasons[0]
          ? planScenarioLabel(plan.reasons[0].scenario)
          : '常规复诊召回',
        priorityScore: plan.priorityScore,
        reasons: plan.reasons.map((r) => ({
          scenarioLabel: planScenarioLabel(r.scenario),
          reason: r.reason,
          priorityScore: r.priorityScore,
        })),
      },
      personaHighlights: (persona?.features ?? []).slice(0, 5).map((f) => ({
        label: personaFeatureMeta(f.key).label,
        description: f.description,
      })),
      clinicalContext: {
        daysSinceLastVisit,
        lastVisitSummary: summarizeLastVisit(latestEnc),
        pendingTreatments: extractPendingTreatments(facts),
        treatmentChainSummary: summarizeChain(encounters),
        primaryDoctorName: extractPrimaryDoctor(facts),
      },
    };
  }
}

// ─────────────────────────────────────────────
// 渲染:LLM 结构化输出 → Markdown(给 PlanScript.content)
// ─────────────────────────────────────────────

/**
 * 渲染整篇 markdown(写到 PlanScript.content 字段)。
 * B 方案:4 段直接拼接(LLM 已经写好每段 markdown,后端不做模板加工)。
 */
function renderMarkdown(
  out: DraftPlanScriptOutput,
  meta: { patientNameMasked: string },
): string {
  return `> 患者:${meta.patientNameMasked}  ·  语气:${toneLabel(out.tone)}

## 开场
${out.opening}

## 切入话题
${out.followup}

## 异议处理
${out.objection}

## 结束 · 信息确认
${out.close}
`;
}

function toneLabel(tone: DraftPlanScriptOutput['tone']): string {
  return { warm: '温和家常', professional: '专业稳重', urgent: '时效紧迫' }[tone];
}

/**
 * 把(可能不完整的)结构化输出渲染成 4 段 ScriptSection,前端直接消费。
 *
 * B 方案设计要点:
 *   - LLM 直出 4 段 markdown(含 `**目的**:` / `**注意**` / `**异议预判**` / `**回写要点**` 子结构)
 *   - 这里不做任何模板加工,只是把 string 字段透传到 sections.markdown
 *   - partial 流式阶段:某段还没生成时为 undefined,渲染成空串(前端 ScriptMarkdown 自然占位)
 *   - 4 段顺序 + id 跟前端 mockScript.sections 完全一致,前端零适配消费
 */
function renderSections(
  out: Partial<DraftPlanScriptOutput>,
  _meta: { patientNameMasked: string },
): ScriptSectionDto[] {
  return [
    {
      id: 'opening',
      label: '开场',
      durationHint: '30 秒',
      markdown: out.opening ?? '',
    },
    {
      id: 'followup',
      label: '切入话题',
      durationHint: '1–2 分钟',
      markdown: out.followup ?? '',
    },
    {
      id: 'objection',
      label: '异议处理',
      durationHint: '按需',
      markdown: out.objection ?? '',
    },
    {
      id: 'close',
      label: '结束 · 信息确认',
      durationHint: '30 秒',
      markdown: out.close ?? '',
    },
  ];
}

// ─────────────────────────────────────────────
// 小工具(从 fact 提炼 LLM 真正需要的几条信息)
// ─────────────────────────────────────────────

function calcAge(birthDate: Date): number {
  const now = new Date();
  let age = now.getFullYear() - birthDate.getFullYear();
  const m = now.getMonth() - birthDate.getMonth();
  if (m < 0 || (m === 0 && now.getDate() < birthDate.getDate())) age--;
  return age;
}

function summarizeLastVisit(enc: FactRow | undefined): string | null {
  if (!enc) return null;
  const c = enc.content as Record<string, unknown> | null;
  const summary = enc.summary ?? (c?.diagnosis as string | undefined) ?? (c?.treatment_category as string | undefined);
  if (!summary && !enc.occurredAt) return null;
  const when = enc.occurredAt ? fmtYearMonth(enc.occurredAt) : '近期';
  return `${when} — ${summary ?? '到店就诊'}`;
}

/**
 * v2.1:读独立 diagnosis_record / recommendation_record fact,找出"有诊断/建议但未启动治疗"的待办。
 * 不再读 encounter_record 嵌套字段(encounter 已退化只元数据)。
 */
function extractPendingTreatments(facts: FactRow[]): string[] {
  // 已做的治疗 category
  const doneCats = new Set<string>();
  for (const f of facts) {
    if (f.type !== 'treatment_record' || f.kind !== 'actual') continue;
    const c = f.content as Record<string, unknown> | null;
    const cat = c?.category as string | undefined;
    if (cat) doneCats.add(cat);
  }
  // 期望治疗 category 映射(跟 chain-composer / treatment-chain-status feature 一致)
  const DX_TO_CAT: Record<string, string[]> = {
    K02: ['restorative'],
    K04: ['endodontic'],
    K05: ['periodontic'],
    K08: ['implant', 'prosthodontic'],
    IMPLANT_RECOMMENDED: ['implant'],
    CROWN_RECOMMENDED: ['prosthodontic'],
    FILLING_RECOMMENDED: ['restorative'],
    SRP_RECOMMENDED: ['periodontic'],
  };
  const items: string[] = [];
  for (const f of facts) {
    if (f.type !== 'diagnosis_record' && f.type !== 'recommendation_record') continue;
    const c = f.content as Record<string, unknown> | null;
    const code = (c?.code as string | undefined) ?? '';
    const name = (c?.name_zh as string | undefined) ?? (c?.name as string | undefined) ?? code;
    const tooth = (c?.tooth_position as string | undefined) ?? '';
    const expectedCats = DX_TO_CAT[code];
    if (!expectedCats) continue;
    const fulfilled = expectedCats.some((cat) => doneCats.has(cat));
    if (fulfilled) continue;
    // 牙位转俗称(LLM 不能对患者说"牙位 21")
    const friendly = tooth ? toothFriendly(tooth) : '';
    items.push(friendly ? `${name}(${friendly})` : name);
  }
  return Array.from(new Set(items)).slice(0, 3);
}

// ─────────────────────────────────────────────
// 事实漂移防护(W4 修)— 诊所/牙位/医生/称呼 必须用真实信息,不让 LLM 编
// ─────────────────────────────────────────────

/**
 * 通话称呼 — "姓 + 先生/女士"(性别未知用"您")。
 *
 * 跟 UI 列表的 maskName 区分:
 *   - UI 列表展示 → maskName("路星") = "路*"(脱敏)
 *   - 客服通话 → nameSpokenForm("路星","男") = "路先生"(真实可念)
 * LLM 的 input.patient.nameMasked 字段用本函数产物(命名是历史,语义已转通话名)。
 */
function nameSpokenForm(name: string | null, gender: string | null): string {
  if (!name) return '您';
  const surname = name.charAt(0);
  if (!surname) return '您';
  if (gender === '男' || gender === 'M' || gender === 'male') return `${surname}先生`;
  if (gender === '女' || gender === 'F' || gender === 'female') return `${surname}女士`;
  return surname ? `${surname}先生` : '您'; // 性别未知默认先生(亚洲诊所语境),可调
}


/**
 * 临时 jvs-dw 诊所字典(TODO #56:接 host clinic 字典或建 PAC clinics 表)。
 * 直接吐 clinicId UUID 进 prompt → LLM 当成乱码会编造"XX 客服中心",必须翻译。
 */
const JVS_DW_CLINIC_NAMES: Record<string, string> = {
  c18cadf2d3cd4adda5527debd41356eb: '通善口腔学前街医院',
  e83d432a38bb4f6284713b36db4e7497: '通善口腔上海世纪公园诊所',
  dad2f04a120947e2b82b41cbd108f3f4: '通善口腔杭州高德诊所',
  '7d49539c7573490387c03e6496ff1a6c': '通善口腔杭州大厦诊所',
  '66701845dd2342e19f9e9f576c4ffe9c': '通善口腔北京朝阳公园诊所',
};

function resolveClinicName(clinicId: string | null): string {
  if (!clinicId) return '本诊所';
  return JVS_DW_CLINIC_NAMES[clinicId] ?? '本诊所';
}

/**
 * FDI 牙位号 → 患者俗称。
 * 设计:粗粒度即可("上门牙"够,不需要"左上中切牙"),让客服讲话自然。
 * 多牙位用"/"分隔,去重保序。
 *
 * FDI 规则:
 *   第 1 位 = 象限(1-4 恒牙,5-8 乳牙)
 *   第 2 位 = 位置(1=中切,2=侧切,3=尖牙,4-5=前磨,6-7=磨牙,8=智齿)
 *   1x/2x 在上颌,3x/4x 在下颌
 */
function toothFriendly(fdiStr: string): string {
  const parts = fdiStr.split(/[;,，;\s]+/).map((s) => s.trim()).filter(Boolean);
  const friendly: string[] = [];
  const seen = new Set<string>();
  for (const p of parts) {
    const label = fdiToFriendly(p);
    if (label && !seen.has(label)) {
      seen.add(label);
      friendly.push(label);
    }
  }
  // 兜底:都解析不出就原样返回(避免空字符串)
  return friendly.length > 0 ? friendly.join('/') : fdiStr;
}

function fdiToFriendly(fdi: string): string | null {
  // 整体全口
  if (fdi === '*whole' || fdi.toLowerCase() === 'whole') return '全口';
  const m = /^([1-8])([1-8])$/.exec(fdi);
  if (!m) return null;
  const q = Number(m[1]);
  const t = Number(m[2]);
  const upper = q === 1 || q === 2 || q === 5 || q === 6;
  const isPrimary = q >= 5; // 乳牙
  const where = upper ? '上' : '下';
  const baby = isPrimary ? '乳' : '';
  // 位置粗粒度
  if (t === 1 || t === 2) return `${where}${baby}门牙`;
  if (t === 3) return `${where}尖牙`;
  if (t === 4 || t === 5) return `${where}小磨牙`;
  if (t === 6 || t === 7) return `${where}大磨牙`;
  if (t === 8) return '智齿';
  return null;
}

/**
 * 从最近的 treatment/diagnosis fact 抽主诊医生名。
 * 没抽到 → null(prompt 里走 fallback,让 LLM 用"您的主诊医生"泛指,不编)。
 */
function extractPrimaryDoctor(facts: FactRow[]): string | null {
  // facts 已按 occurredAt desc 排序,优先 treatment/diagnosis(更"主诊"),其次 emr/encounter
  const candidates = facts.filter(
    (f) => f.type === 'treatment_record' || f.type === 'diagnosis_record',
  );
  for (const f of candidates) {
    const c = f.content as Record<string, unknown> | null;
    const name = (c?.doctor_name as string | undefined)?.trim();
    if (name && name.length > 0 && name.length <= 10) return name;
  }
  // 兜底:再扫所有 fact 任一 doctor_name
  for (const f of facts) {
    const c = f.content as Record<string, unknown> | null;
    const name = (c?.doctor_name as string | undefined)?.trim();
    if (name && name.length > 0 && name.length <= 10) return name;
  }
  return null;
}

function summarizeChain(encounters: FactRow[]): string | null {
  if (encounters.length === 0) return null;
  // v2.1:encounter_record 只元数据,不再有 treatment_stage / treatment_category 嵌套
  return `共 ${encounters.length} 次就诊记录`;
}

// ─────────────────────────────────────────────
// 类型(避免循环依赖,本地声明 Prisma 结构投影)
// ─────────────────────────────────────────────

type PlanWithReasons = Prisma.FollowupPlanGetPayload<{
  include: { reasons: true };
}>;

type PatientRow = Prisma.PatientGetPayload<{}>;

type PersonaWithFeatures = Prisma.PersonaGetPayload<{
  include: { features: true };
}>;

type FactRow = Prisma.PatientFactGetPayload<{}>;
