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 { WecomPlanCall, WecomWriteCall, WecomVerifyCall } from './calls';
import type { WecomPlanZ, WecomWriteZ, WecomVerifyZ } from './schema';

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

/**
 * 企微话术编排:规划(plan)→ 写(write,单块)→ 独立对抗校验(verify)→ 不过则 repair(≤1 轮)。
 *
 * ⚠️⚠️ **与电话档最大的行为差别:没有模板兜底,失败就是失败。**
 *   电话档失败会回退到 `stableTemplateFallback` —— 因为客服正拿着电话,必须有东西能念,
 *   而模板稿念出来只是"平淡",不会出事。
 *   企微不一样:产出是**一条要原样发给患者的消息**。给一份模板套话让他复制发出去,
 *   患者收到的是一条明显是群发的、可能对不上他情况的消息 —— 那比"没有话术"更糟,
 *   而且发出去收不回。⇒ 生成不出来就如实报失败,让客服自己写。
 *
 * 其余脊柱与电话深度档一致:每步各过 AiCallRunner、各落 agent_invocations;
 * 机器安全(禁词/承诺/加粗时间)在策略侧硬扫,与 LLM 校验合并决定是否 repair。
 */
@Injectable()
export class WecomScriptStrategy {
  private readonly logger = new Logger(WecomScriptStrategy.name);
  private readonly MAX_REPAIR = 1;

  constructor(
    private readonly runner: AiCallRunnerService,
    private readonly planCall: WecomPlanCall,
    private readonly writeCall: WecomWriteCall,
    private readonly verifyCall: WecomVerifyCall,
  ) {}

  /**
   * 流式版:边跑边 yield 步骤事件,最后 yield 结果。
   *
   * ⭐ 事件形状**刻意与电话深度档对齐**(`{kind:'step', step, status, detail}`)——
   *   前端那套「深度过程可见」的时间线组件(ScriptDeepProcess)因此原样复用,
   *   ⛔ 不必为企微再画一套。企微一轮要跑 60 秒上下,没有过程可见就是一个转圈的白屏。
   * ⚠️ 这里**不推正文增量**:企微稿是一整块,逐字推过去除了闪没有别的意义
   *   (电话档推的是"段",那是有结构的)。
   */
  async *runStream(
    ctx: ScriptContext,
    runCtx: AiCallContext,
  ): AsyncGenerator<
    | { kind: 'step'; step: 'plan' | 'write' | 'verify' | 'repair'; status: 'running' | 'done'; detail?: { pass?: boolean; issuesCount?: number } }
    | { kind: 'result'; result: WecomScriptResult }
  > {
    const emit: Array<{ step: 'plan' | 'write' | 'verify' | 'repair'; status: 'running' | 'done'; detail?: { pass?: boolean; issuesCount?: number } }> = [];
    // 复用 run() 的全部逻辑,只是把它的步骤回调接出来 —— ⛔ 别把 run() 抄一遍改成生成器,
    // 那样两条路径的兜底/闸门会各活各的,而它们必须一致。
    const collect = (e: (typeof emit)[number]) => emit.push(e);
    const runner = this.run(ctx, runCtx, collect);
    // 边跑边把已产生的事件吐出去(轮询本地队列;步骤粒度秒级,这个精度足够)
    let sent = 0;
    let done = false;
    const p = runner.finally(() => {
      done = true;
    });
    while (!done) {
      while (sent < emit.length) yield { kind: 'step', ...emit[sent++]! };
      await new Promise((r) => setTimeout(r, 120));
    }
    while (sent < emit.length) yield { kind: 'step', ...emit[sent++]! };
    yield { kind: 'result', result: await p };
  }

  async run(
    ctx: ScriptContext,
    runCtx: AiCallContext,
    onStep?: (e: { step: 'plan' | 'write' | 'verify' | 'repair'; status: 'running' | 'done'; detail?: { pass?: boolean; issuesCount?: number } }) => void,
  ): Promise<WecomScriptResult> {
    const step = (
      s: 'plan' | 'write' | 'verify' | 'repair',
      status: 'running' | 'done',
      detail?: { pass?: boolean; issuesCount?: number },
    ) => onStep?.({ step: s, status, detail });
    const steps: string[] = [];
    let cost = 0;
    let promptTokens = 0;
    let completionTokens = 0;
    const acc = (r: { costYuan: number; promptTokens: number; completionTokens: number }) => {
      cost += r.costYuan;
      promptTokens += r.promptTokens;
      completionTokens += r.completionTokens;
    };
    const ensureLive = () => {
      if (runCtx.signal?.aborted) throw new Error('生成已取消(客户端断连)');
    };
    const fail = (reason: string, invocationId = ''): WecomScriptResult => ({
      markdown: '',
      tone: 'warm',
      source: 'failed',
      invocationId,
      costYuan: cost,
      promptTokens,
      completionTokens,
      stepsRun: steps,
      failReason: reason,
    });

    // ── 步骤1:要点规划(best-effort —— 失败不阻断,让 write 直接按事实自己排)──
    ensureLive();
    step('plan', 'running');
    let plan: WecomPlanZ | undefined;
    try {
      const r = await this.runner.run(this.planCall, ctx, runCtx);
      acc(r);
      plan = r.output;
      steps.push('plan');
      step('plan', 'done');
    } catch (err) {
      if (runCtx.signal?.aborted) throw err;
      this.logger.warn(`wecom plan 失败,跳过规划直接写: ${(err as Error).message}`);
      plan = { points: [] };
      steps.push('plan:skip');
      step('plan', 'done');
    }

    // ── 步骤2:写(单块)──
    ensureLive();
    step('write', 'running');
    let w;
    try {
      w = await this.runner.run(this.writeCall, { ctx, plan }, runCtx);
    } catch (err) {
      if (runCtx.signal?.aborted) throw err;
      return fail(`写作失败: ${(err as Error).message}`);
    }
    acc(w);
    let draft = w.output;
    let invocationId = w.invocationId;
    steps.push('write');
    step('write', 'done');

    // ── 步骤3:对抗校验 + 机器扫 ──
    const issues: WecomVerifyZ['issues'] = machineScanIssues(draft);
    ensureLive();
    step('verify', 'running');
    try {
      const v = await this.runner.run(this.verifyCall, { ctx, draft }, runCtx);
      acc(v);
      steps.push('verify');
      if (!v.output.pass) issues.push(...v.output.issues);
      step('verify', 'done', { pass: v.output.pass, issuesCount: issues.length });
    } catch (err) {
      if (runCtx.signal?.aborted) throw err;
      this.logger.warn(`wecom verify 失败,仅依据机器扫: ${(err as Error).message}`);
      steps.push('verify:skip');
      step('verify', 'done', { issuesCount: issues.length });
    }

    // ── repair(≤1 轮)──
    if (issues.length > 0) {
      ensureLive();
      this.logger.debug(`wecom repair: ${issues.length} 个 issue`);
      step('repair', 'running', { issuesCount: issues.length });
      try {
        const w2 = await this.runner.run(
          this.writeCall,
          { ctx, plan, repairIssues: issues, prevDraft: draft },
          runCtx,
        );
        acc(w2);
        draft = w2.output;
        invocationId = w2.invocationId;
        steps.push('repair');
        step('repair', 'done');
      } catch (err) {
        if (runCtx.signal?.aborted) throw err;
        return fail(`修订失败: ${(err as Error).message}`, invocationId);
      }
      // 终检:机器闸仍不过 → ⛔ **不放行**(对抗哲学:接地/安全宁可没有也不发错的)。
      // ⚠️ 必须复用 machineScanIssues(而不是只调 machineSafetyScan)——
      //   占位符残留那条闸只在前者里,漏掉的话修订后仍带【时间段】会被直接放行。
      const stillBad = machineScanIssues(draft);
      if (stillBad.length > 0) {
        return fail(`修订后仍不过机器安全闸: ${stillBad.map((i) => i.problem).join(';')}`, invocationId);
      }
    }

    return {
      markdown: draft.markdown,
      tone: draft.tone,
      source: 'agent',
      invocationId,
      costYuan: cost,
      promptTokens,
      completionTokens,
      stepsRun: steps,
    };
  }
}

/**
 * 机器硬扫 → issue(与 LLM 校验合并)。
 * ⚠️ 复用电话档同一个 `machineSafetyScan`:禁词/疗效承诺/加粗写死时间这些**与渠道无关**,
 *   而且它是"单一源"——两处各写一份的话,改了其中一处另一处就悄悄留在旧规则上。
 */
function machineScanIssues(draft: WecomWriteZ): WecomVerifyZ['issues'] {
  const issues: WecomVerifyZ['issues'] = machineSafetyScan(draft.markdown).map((problem) => ({
    section: '整体',
    problem,
    fix: '按机器安全闸要求改掉该处(不报价 / 不承诺疗效 / ≤18 不提拍片)',
  }));

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