import { Injectable, Logger } from '@nestjs/common';
import type { AiCall } from '../../ai-call.interface';
import type { SafetyRule } from '../../core/safety-gate.service';
import { DraftPlanScriptSchema } from './schema';
import type { DraftPlanScriptInput, DraftPlanScriptOutput } from './input.types';
import {
  DRAFT_PLAN_SCRIPT_PROMPT_VERSION_LEGACY,
  DRAFT_PLAN_SCRIPT_SYSTEM_LEGACY,
  buildDraftPlanScriptPrompt,
} from './prompt';
import { composeSystem } from './skill-composer';
import { DraftPlanScriptSkillRegistry } from './skill-registry.service';

/**
 * Safety rules — 后置硬约束。
 * LLM 偶尔会越过 schema 约束塞禁词,这里再补一道(防御性)。
 * B 方案重写后,output 4 段都是 markdown 字符串,直接全文 join 扫禁词。
 */
// ⚠️ 单字符禁词务必避免(会误伤合法词):
//   '亲' → 误命中 亲切 / 亲自 / 亲人 / 母亲 / 父亲 等
//   '宝' → 误命中 宝贝 / 宝藏 / 宝石 等
// 只保留"销售化"的明确组合形式
const FORBIDDEN_PHRASES = [
  '一定能', '保证', '绝对', '百分百', '100%',
  '亲爱的',         // 淘宝式称呼,误伤面比单字符 '亲' 小很多
  '便宜', '促销', '折扣', '免费送',
];

const safetyRules: ReadonlyArray<SafetyRule<DraftPlanScriptOutput>> = [
  {
    name: 'no_forbidden_phrases',
    severity: 'block',
    check(output) {
      const fullText = [output.opening, output.followup, output.objection, output.close].join('\n');
      const hit = FORBIDDEN_PHRASES.filter((p) => fullText.includes(p));
      return {
        pass: hit.length === 0,
        message: hit.length > 0 ? `命中禁词: ${hit.join(',')}` : undefined,
      };
    },
  },
  {
    name: 'close_has_concrete_time',
    severity: 'warn', // warn 不阻断,只记日志
    check(output) {
      // 启发式:结束段 markdown 必须含数字(具体时间点 / 日期)
      const hasDigit = /\d/.test(output.close);
      return { pass: hasDigit, message: hasDigit ? undefined : 'close 段未含具体时间数字' };
    },
  },
  {
    name: 'objection_uses_h3_blocks',
    severity: 'warn',
    check(output) {
      // 启发式:objection 段必须按 ### A./B./C. 子标题切分
      const hasH3 = /^###\s+[A-D]\./m.test(output.objection);
      return { pass: hasH3, message: hasH3 ? undefined : 'objection 段未按 ### A./B. 子标题切分' };
    },
  },
  {
    name: 'close_no_commit_phrasing',
    severity: 'block',
    check(output) {
      // close 段禁止"我已为您约好 X" / "已成功预约 X" / "约定 / 敲定 / 安排好" 这种确定承诺(PAC 无排班 API)
      const COMMIT_PHRASES = [
        '已为您约好', '已成功预约', '已为您预约', '已经为您约', '已替您预约',
        '约定本', '敲定本', '安排好了', '已经预约'
      ];
      const hit = COMMIT_PHRASES.filter((p) => output.close.includes(p));
      return {
        pass: hit.length === 0,
        message: hit.length > 0 ? `close 段承诺式表述(无排班 API,不能定): ${hit.join(',')}` : undefined,
      };
    },
  },
  {
    name: 'close_no_bold_time',
    severity: 'block',
    check(output) {
      // close 段禁止 **本周X上午X点** 这种加粗具体时间 — 加粗 = "已确认重点",误导患者
      //   正确做法:具体时间紧跟 (示例) 后缀,或用"周X上午这个方向"模糊词
      //   匹配:**...含数字时间词的字符串...**
      const boldTimeRegex = /\*\*[^*\n]*(?:周[一二三四五六日天]|\d+\s*(?:点|:|：))[^*\n]*\*\*/;
      const m = output.close.match(boldTimeRegex);
      return {
        pass: !m,
        message: m ? `close 段加粗了具体时间"${m[0]}" — 应去加粗 + 加 (示例) 后缀或用方向词` : undefined,
      };
    },
  },
  {
    name: 'close_has_tentative_phrasing',
    severity: 'warn', // warn 不阻断,只记日志(soft 引导,严格的话改 block)
    check(output) {
      // close 段应含"待确认"语义短语,避免患者以为时间真定了
      const TENTATIVE_PHRASES = [
        '以诊所排班为准',
        '排班为准',
        '稍后跟前台确认',
        '跟前台确认',
        '稍后跟诊所确认',
        '稍后短信确认',
        '排班确认后告知',
        '排班确认后短信',
        '稍后短信通知您实际',
        '具体时段以',
        '具体时间以',
      ];
      const hasTentative = TENTATIVE_PHRASES.some((p) => output.close.includes(p));
      return {
        pass: hasTentative,
        message: hasTentative ? undefined : 'close 段未含"待确认/以排班为准"等弱化短语,可能误导患者以为时间已定',
      };
    },
  },
];

/**
 * 降级 fallback —— LLM 失败 / safety 拒收时用。
 * 用 input 直接拼一份 4 段 markdown 模板话术,保证客服一定有东西可用。
 *
 * ⚠️ fallback 文本本身也要过 safety rule(close_no_bold_time / close_has_tentative_phrasing)。
 * 历史踩坑:close 段写 `**本周六上午 10 点**` 加粗时间,自己触发 close_no_bold_time block。
 * 已改:不加粗 + (示例) 后缀 + 显式"以诊所排班为准"。
 */
function fallback(input: DraftPlanScriptInput): DraftPlanScriptOutput {
  const { patient, clinicName, plan, clinicalContext } = input;
  const dayPart = clinicalContext.daysSinceLastVisit
    ? `已经 ${clinicalContext.daysSinceLastVisit} 天没见您了`
    : '有段时间没见您了';
  const lastSummary = clinicalContext.lastVisitSummary ?? '复查相关安排';

  return {
    tone: 'warm',
    opening: `**目的**:亲切自然地建立通话,围绕「${plan.primaryScenarioLabel}」开场,避免推销感。

> "${patient.nameMasked}您好,我是${clinicName}的客服。${dayPart},今天给您打电话,主要是想跟您同步上次的${lastSummary},您现在方便聊几分钟吗?"

**注意**
- 称呼用「${patient.nameMasked}」,不用全名
- 若不方便,主动询问合适回拨时间`,
    followup: `**目的**:把诊所记录的临床事实,自然引到「该回来做点什么」。

> "根据病历记录,本次想跟您沟通的是:${plan.primaryScenarioLabel}。复查不需要太久,大概 20 分钟就能完成。"

> "我们这边可以帮您安排医生面诊评估,**这次只是评估和确认方案,不做任何操作**,您看本周末或下周初哪个时间方便?"

**异议预判**
- "最近没时间" → 提供更灵活的时间窗(早班/晚班)
- "再考虑一下" → 强调诊后复查窗口期,过期可能要重新评估
- "已在外院看过" → 提交「已在外院治疗」并关闭召回`,
    objection: `### A. "我再考虑考虑"
> "完全理解。这样,我先帮您把医生的面诊时间留出来,本周六上午或下周一晚上,您选一个?到现场看了方案再决定也不晚。"

### B. "最近真的没时间"
> "理解,可以约到下个月,提前预约能避开排队。您下周或下下周哪天比较方便?我先帮您预留。"

### C. "已在别的医院看了"
> "好的${patient.nameMasked},那我这边帮您把这条记录关一下,日常护理还是按原来的周期回来就行,祝您一切顺利。"
> → 提交结果选「已在外院治疗」`,
    close: `> "好的${patient.nameMasked},我先按 周六上午10点(示例) 帮您登记面诊时间,具体时段以诊所排班为准,稍后跟前台确认后短信通知您实际时间。还有别的需要么?"

**回写要点**
- 成功约上面诊 → 提交结果选「成功转化为新预约」,填预约时间 + 医生
- 同意但未定日期 → 选「约定下次回访」,填预计时间
- 考虑中 → 选「考虑中近期再跟进」,7 天后系统提醒二次跟进`,
  };
}

/**
 * 是否启用 Skills 系统(env 开关,出问题秒回滚)。
 * 默认 1 启用;设 0 退回 legacy 单 prompt 路径。
 */
function isSkillsEnabled(): boolean {
  return (process.env.AI_SCRIPT_USE_SKILLS ?? '1') !== '0';
}

/**
 * Skills 模式 promptVersion(base 版本,跟 legacy 区分;
 * composeHash 可以在 agent_invocations.input_snapshot.skills_used 看到具体装配)。
 */
const DRAFT_PLAN_SCRIPT_PROMPT_VERSION_SKILLS =
  'draft_plan_script@2026-05-27-skills-base-v1';

@Injectable()
export class DraftPlanScriptCall
  implements AiCall<DraftPlanScriptInput, DraftPlanScriptOutput>
{
  private readonly logger = new Logger(DraftPlanScriptCall.name);
  readonly kind = 'script' as const;
  readonly callKey = 'draft_plan_script';
  // ⚠️ promptVersion 选 skills 版还是 legacy 版,取决于 env;
  //   两版输出差异会让 agent_invocations.promptVersion 区分开,SQL 对比效果时拆分群体
  readonly promptVersion = isSkillsEnabled()
    ? DRAFT_PLAN_SCRIPT_PROMPT_VERSION_SKILLS
    : DRAFT_PLAN_SCRIPT_PROMPT_VERSION_LEGACY;
  readonly defaultModelId = 'deepseek-v4-flash';
  readonly outputSchema = DraftPlanScriptSchema;
  readonly safetyRules = safetyRules;

  constructor(private readonly skillRegistry: DraftPlanScriptSkillRegistry) {}

  buildPrompt(input: DraftPlanScriptInput) {
    if (!isSkillsEnabled()) {
      // legacy 路径 — 老 prompt 单系统词,user prompt 不传 matchedSkills
      return {
        system: DRAFT_PLAN_SCRIPT_SYSTEM_LEGACY,
        prompt: buildDraftPlanScriptPrompt(input, []),
      };
    }

    // skills 路径 — composer 装配 system + user prompt 末尾追加 skills 清单
    const composed = composeSystem(input, this.skillRegistry.getAllSkills());
    if (composed.matchedSkills.length === 0) {
      this.logger.warn(
        `compose 0 个 skill 命中(scenario=${composed.context.scenario}, ` +
          `dx=${composed.context.diagnosisCodes.join(',')}, ` +
          `pop=${composed.context.population}, rel=${composed.context.relationship}) — ` +
          `system 回退仅 base 部分,可能效果下降`,
      );
    } else {
      this.logger.debug(
        `compose skills: ${composed.matchedSkills.map((s) => s.frontmatter.name).join(', ')} ` +
          `(hash=${composed.composeHash})`,
      );
    }
    return {
      system: composed.systemPrompt,
      prompt: buildDraftPlanScriptPrompt(input, composed.matchedSkills),
    };
  }

  fallback(input: DraftPlanScriptInput): DraftPlanScriptOutput {
    return fallback(input);
  }
}
