import { Injectable, Logger } from '@nestjs/common';
import { AiCallRunnerService } from '../../ai-call-runner.service';
import type { AiCallContext } from '../../ai-call.interface';
import type { ScriptContext } from '../draft-plan-script/shared/input.types';
import { machineSafetyScan } from '../draft-plan-script/shared/safety-rules';
import { WecomWriteCall } from './calls';
import type { WecomWriteZ } from './schema';

export interface WecomScriptResult {
  markdown: string;
  tone: WecomWriteZ['tone'];
  source: 'agent' | 'failed';
  invocationId: string;
  costYuan: number;
  promptTokens: number;
  completionTokens: number;
  failReason?: string;
}

/**
 * 企微话术 —— **单次流式生成**,没有工作流。
 *
 * ═══ 2026-08 从三步 pipeline 砍成一步 ═══════════════════════════════
 * 原来是 规划大纲 → 撰写 → 安全自检 →(不过)修订,四次 LLM 调用、一轮 60 秒上下,
 * 前端还得画一套"过程可见"的时间线陪着转。砍掉的理由:
 *   · 企微稿是**一整块几百字**,没有分段结构 —— "先列大纲"对单块文本收益极小
 *   · 客服拿到后**本来就要自己过一遍再发**(界面上一直写着"请核对后使用"),
 *     LLM 自检那一步是在给一个人本来就会做的事再花一次钱和 30 秒
 *   · 流式输出让"等"变成"看着它写",体感比一个转圈的白屏好得多
 *
 * ⚠️ **机器安全闸保留**(不是 LLM 那步)。它不花钱、不耗时,而且拦的是
 *   模型自己发现不了的东西 —— 尤其**残留占位符**:企微是整段复制直发,
 *   `【时间段1】` 会原样进患者微信。实测模型会照抄电话档的习惯写出占位符,
 *   ⛔ 只靠 prompt 拦不住。
 *
 * ⚠️⚠️ **没有模板兜底,失败就是失败**(与电话档最大的行为差别)。
 *   电话档失败回退模板 —— 客服正拿着电话,必须有东西能念,模板稿只是平淡不会出事。
 *   企微产出是**一条要原样发给患者的消息**:给一份群发感的套话让他复制发出去,
 *   患者收到后收不回来,那比"没有话术"更糟。⇒ 生成不出来就如实报失败,让客服自己写。
 */
@Injectable()
export class WecomScriptStrategy {
  private readonly logger = new Logger(WecomScriptStrategy.name);

  constructor(
    private readonly runner: AiCallRunnerService,
    private readonly writeCall: WecomWriteCall,
  ) {}

  /**
   * 流式:边写边推正文增量,写完过机器闸。
   *
   * ⭐ 推的是 `markdown` 的**增量**(不是整串重发)—— 前端直接追加,不必每帧重排整段。
   * ⚠️ 机器闸在 `done` 之后才判:不过则**丢弃已推的正文**、回一个 failed。
   *   看着"写了又没了"确实不好受,但让带占位符的稿子留在框里、客服一键复制发出去更糟。
   */
  async *runStream(
    ctx: ScriptContext,
    runCtx: AiCallContext,
  ): AsyncGenerator<
    | { kind: 'delta'; text: string }
    | { kind: 'result'; result: WecomScriptResult }
  > {
    let pushed = ''; // 已推出去的正文,用来算增量
    let last: { output: WecomWriteZ; invocationId: string; costYuan: number; promptTokens: number; completionTokens: number } | null = null;

    try {
      for await (const ev of this.runner.stream(this.writeCall, { ctx }, runCtx)) {
        if (ev.type === 'partial') {
          const md = (ev.partial as Partial<WecomWriteZ>).markdown ?? '';
          // ⚠️ 只在**变长**时推增量:structured 流式偶发回吐更短的中间态,
          //   直接 slice 会推出乱码片段。
          if (md.length > pushed.length && md.startsWith(pushed)) {
            yield { kind: 'delta', text: md.slice(pushed.length) };
            pushed = md;
          }
        } else if (ev.type === 'done') {
          last = {
            output: ev.output,
            invocationId: ev.invocationId,
            costYuan: ev.costYuan,
            promptTokens: ev.promptTokens,
            completionTokens: ev.completionTokens,
          };
        }
      }
    } catch (err) {
      if (runCtx.signal?.aborted) throw err;
      yield { kind: 'result', result: this.fail(`生成失败: ${(err as Error).message}`) };
      return;
    }

    if (!last) {
      yield { kind: 'result', result: this.fail('生成失败: 模型没有返回结果') };
      return;
    }

    const issues = machineScanIssues(last.output);
    if (issues.length > 0) {
      this.logger.warn(`wecom 机器安全闸未过(${issues.length}): ${issues.join('; ')}`);
      yield {
        kind: 'result',
        result: this.fail(`未通过安全检查: ${issues.join(';')}`, last.invocationId, last),
      };
      return;
    }

    yield {
      kind: 'result',
      result: {
        markdown: last.output.markdown,
        tone: last.output.tone,
        source: 'agent',
        invocationId: last.invocationId,
        costYuan: last.costYuan,
        promptTokens: last.promptTokens,
        completionTokens: last.completionTokens,
      },
    };
  }

  /** 非流式(重新生成端点 / 测试用)—— 与流式**同一条闸**,⛔ 别让两条路各判各的 */
  async run(ctx: ScriptContext, runCtx: AiCallContext): Promise<WecomScriptResult> {
    let result: WecomScriptResult | null = null;
    for await (const ev of this.runStream(ctx, runCtx)) {
      if (ev.kind === 'result') result = ev.result;
    }
    return result ?? this.fail('生成失败: 未产出结果');
  }

  private fail(
    reason: string,
    invocationId = '',
    usage?: { costYuan: number; promptTokens: number; completionTokens: number },
  ): WecomScriptResult {
    return {
      markdown: '',
      tone: 'warm',
      source: 'failed',
      invocationId,
      costYuan: usage?.costYuan ?? 0,
      promptTokens: usage?.promptTokens ?? 0,
      completionTokens: usage?.completionTokens ?? 0,
      failReason: reason,
    };
  }
}

/**
 * 机器硬扫 —— 唯一保留的"检查",不花钱不耗时。
 *
 * ⚠️ 禁词/疗效承诺/加粗写死时间复用电话档同一个 `machineSafetyScan`:这些**与渠道无关**,
 *   两处各写一份的话,改了其中一处另一处就悄悄留在旧规则上。
 */
function machineScanIssues(draft: WecomWriteZ): string[] {
  const issues: string[] = machineSafetyScan(draft.markdown);

  /**
   * ⭐ 企微专有硬闸:**除【回访客服】外不许残留任何 `【】` 占位**。
   *
   * 电话稿留【时间段1】是对的(客服边打边填);企微这条消息是**整段复制直接发出去**的,
   * 占位符会原样发到患者微信里 —— 要么客服忘了改直接发出去,要么他得先手动编辑一遍,
   * 而"可直接复制发送"当场就不成立了。
   * ⚠️ 只靠 prompt 拦不住(实测模型照着电话档的习惯写出来了),所以这里硬扫兜底。
   */
  const leftovers = [...draft.markdown.matchAll(/【([^】]*)】/g)]
    .map((m) => m[0])
    .filter((tag) => tag !== '【回访客服】');
  if (leftovers.length) {
    issues.push(`残留占位符 ${[...new Set(leftovers)].join('、')} —— 企微是整段复制直发,这会原样发给患者`);
  }
  return issues;
}
