import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { generateObject, streamObject } from 'ai';
import { z, type ZodSchema } from 'zod';
import type { Prisma } from '@prisma/client';
import type { AppConfig } from '../../config/configuration';
import { AiProviderService } from './core/ai-provider.service';
import { InvocationRecorderService } from './core/invocation-recorder.service';
import { PromptCacheService } from './core/prompt-cache.service';
import { SafetyGateRejectError, SafetyGateService } from './core/safety-gate.service';
import { computeInputHash } from './core/hash.util';
import type { AiCall, AiCallContext, AiCallResult } from './ai-call.interface';
import { estimateCostYuan } from './core/cost';

/**
 * 流式事件 — orchestrator / controller 转换成 SSE 后吐给客户端
 */
export type StreamEvent<TOutput> =
  | { type: 'start'; invocationId: string; modelId: string; promptVersion: string }
  | { type: 'partial'; partial: Partial<TOutput> }
  | {
      type: 'done';
      output: TOutput;
      source: 'agent' | 'template_fallback';
      invocationId: string;
      promptTokens: number;
      completionTokens: number;
      costYuan: number;
      fallbackReason?: string;
    }
  | { type: 'error'; message: string; invocationId: string };

/**
 * AiCallRunner — harness 的执行器。
 *
 * 把 call 定义 + input + 上下文 跑通统一流程:
 *   1. 计算 inputHash
 *   2. 查缓存(可选 bustCache 跳过)
 *      命中 → 落 cached invocation → 返回 cached output
 *   3. 起 invocation(status=running)
 *   4. 调 generateObject(model + schema + system + prompt)
 *   5. safety gate 校验 → 失败走 fallback
 *   6. LLM 失败 → 走 fallback
 *   7. 落 invocation(succeeded / failed)+ 写缓存
 *   8. 返回 AiCallResult
 *
 * 任何 AiCall 都通过本 runner 跑,不允许业务代码自己 new generateObject。
 * 这是 audit / cost / eval / cache 一致性的唯一保证。
 */
@Injectable()
export class AiCallRunnerService {
  private readonly logger = new Logger(AiCallRunnerService.name);

  constructor(
    private readonly provider: AiProviderService,
    private readonly cache: PromptCacheService,
    private readonly recorder: InvocationRecorderService,
    private readonly safety: SafetyGateService,
    private readonly config: ConfigService<AppConfig, true>,
  ) {}

  async run<TInput, TOutput>(
    call: AiCall<TInput, TOutput>,
    input: TInput,
    ctx: AiCallContext,
  ): Promise<AiCallResult<TOutput>> {
    const inputHash = computeInputHash(call.callKey, call.promptVersion, input);
    const requestedModelId = ctx.modelIdOverride ?? call.defaultModelId;
    // resolve 返回规范化具体 modelId(裸键 "gemini" → "gemini-2.5-flash"),落账 / 计费都用它
    const { model, provider, modelId } = this.provider.resolve(requestedModelId);

    // ─── 1. 缓存查询 ───
    if (!ctx.bustCache) {
      const cached = this.cache.get<TOutput>(inputHash);
      if (cached !== undefined) {
        const invId = await this.recorder.start({
          ...this.baseStart(call, ctx, modelId, provider, inputHash, input, true),
        });
        await this.recorder.end(invId, { status: 'cached', output: cached as Prisma.InputJsonValue });
        this.logger.debug(`cache hit call=${call.callKey} hash=${inputHash.slice(0, 8)}`);
        return {
          output: cached,
          invocationId: invId,
          cacheHit: true,
          source: 'agent',
          promptTokens: 0,
          completionTokens: 0,
          costYuan: 0,
        };
      }
    }

    // ─── 2. 起 invocation ───
    const built = call.buildPrompt(input);
    const prompt = built.prompt;
    const system = withQwenStructuredHint(provider, built.system, call.outputSchema); // qwen(DashScope)需把 key 骨架注入 prompt
    const invocationId = await this.recorder.start({
      ...this.baseStart(call, ctx, modelId, provider, inputHash, input, false),
      promptTemplate: prompt.length > 8000 ? prompt.slice(0, 8000) + '…[truncated]' : prompt,
      systemPrompt: system.length > 40000 ? system.slice(0, 40000) + '…[truncated]' : system,
    });

    // ─── 3. 调 LLM ───
    try {
      // 类型说明:AI SDK v6 的 generateObject 是判别联合类型(基于 output mode),
      // TOutput 泛型在 runner 这层无法窄化,用 any 收口调用,runtime 由 Zod schema 校验。
      // 出参立刻 cast 回 TOutput,边界只在这一行。
      // ⚠️ JSON 解析失败重试:flash 等模型偶发吐不出合法 JSON(NoObjectGeneratedError),
      //    温度采样下重试一次常能过;首次失败就兜底太急(深度档多段 schema 尤其明显)。
      // eslint-disable-next-line @typescript-eslint/no-explicit-any
      let result: Awaited<ReturnType<typeof generateObject<any>>> | undefined;
      const MAX_ATTEMPTS = 2;
      let lastErr: unknown;
      // 超时兜底(防 LLM 永久挂起 → 一直 running)。
      // 注:gemini-3.5-flash 是推理模型、生成偏慢,但**不关它的思考**——实测 thinkingBudget:0
      //     会致结构化输出偶发 parse 失败(且 gemini API 延迟本就抖)→ 让它正常思考更可靠,
      //     慢/卡由 withTimeout(180s)+ 重试 + 模板兜底兜住。
      const callSignal = withTimeout(ctx.signal, this.requestTimeoutMs());
      const callStart = Date.now(); // 计时:非流式只有总耗时(latency),无 TTFT
      for (let attempt = 0; attempt < MAX_ATTEMPTS; attempt++) {
        try {
          // eslint-disable-next-line @typescript-eslint/no-explicit-any
          result = await generateObject<any>({ model, schema: call.outputSchema, system, prompt, abortSignal: callSignal });
          break;
        } catch (e) {
          lastErr = e;
          if (ctx.signal?.aborted) break; // 客户端断连 → 别重试,直接抛(下方 abort 分支不兜底)
          if (attempt < MAX_ATTEMPTS - 1) {
            this.logger.warn(
              `call=${call.callKey} generateObject 第 ${attempt + 1} 次失败,重试: ${(e as Error).message}`,
            );
          }
        }
      }
      if (!result) throw lastErr;

      const output = result.object as TOutput;

      // ─── 4. safety gate ───
      if (call.safetyRules && call.safetyRules.length > 0) {
        try {
          this.safety.check(output, call.safetyRules, {
            callKey: call.callKey,
            promptVersion: call.promptVersion,
            tenantId: ctx.tenantId,
          });
        } catch (err) {
          if (err instanceof SafetyGateRejectError) {
            return this.fallback(call, input, ctx, invocationId, `safety: ${err.message}`);
          }
          throw err;
        }
      }

      // ─── 5. 成功落账 ───
      const usage = result.usage ?? { inputTokens: 0, outputTokens: 0, totalTokens: 0 };
      const promptTokens = usage.inputTokens ?? 0;
      const completionTokens = usage.outputTokens ?? 0;
      const cachedInputTokens = (usage as { cachedInputTokens?: number }).cachedInputTokens ?? 0;
      const reasoningTokens = (usage as { reasoningTokens?: number }).reasoningTokens ?? 0;
      this.logger.log(
        `[usage] call=${call.callKey} usage=${JSON.stringify(usage)} ` +
          `providerMetadata=${JSON.stringify(result.providerMetadata ?? null)}`,
      );
      const costYuan = this.estimateCostYuan(modelId, promptTokens, completionTokens, cachedInputTokens);

      await this.recorder.end(invocationId, {
        output: output as Prisma.InputJsonValue,
        promptTokens,
        completionTokens,
        totalTokens: usage.totalTokens ?? promptTokens + completionTokens,
        cachedInputTokens,
        reasoningTokens,
        costYuan,
        latencyMs: Date.now() - callStart,
        status: 'succeeded',
      });

      this.cache.set(inputHash, output);

      return {
        output,
        invocationId,
        cacheHit: false,
        source: 'agent',
        promptTokens,
        completionTokens,
        costYuan,
      };
    } catch (err) {
      const errMsg = err instanceof Error ? err.message : String(err);
      // 客户端断连/取消 → 不兜底、不落 PlanScript,只记一笔 aborted 后抛出
      if (ctx.signal?.aborted) {
        await this.recorder.end(invocationId, { status: 'failed', errorMessage: `aborted: ${errMsg}` });
        throw err;
      }
      this.logger.warn(`call=${call.callKey} LLM 失败: ${errMsg}`);
      return this.fallback(call, input, ctx, invocationId, `llm: ${errMsg}`);
    }
  }

  /**
   * 流式版本 — 返回 AsyncGenerator,逐步 yield partial 对象。
   *
   * 设计取舍:
   *   - 流式时**始终绕过缓存**(命中缓存的话流式没意义,客户端直接拿 done 即可,
   *     由 orchestrator/controller 决定是否走流式)
   *   - safety gate / fallback / 落库 都在 stream 收尾时统一做
   *   - generateObject 的 partial 是渐进式构造 JSON,字段顺序按 schema 定义
   */
  async *stream<TInput, TOutput>(
    call: AiCall<TInput, TOutput>,
    input: TInput,
    ctx: AiCallContext,
  ): AsyncGenerator<StreamEvent<TOutput>, void, void> {
    const inputHash = computeInputHash(call.callKey, call.promptVersion, input);
    const requestedModelId = ctx.modelIdOverride ?? call.defaultModelId;
    const { model, provider, modelId } = this.provider.resolve(requestedModelId);
    const built = call.buildPrompt(input);
    const prompt = built.prompt;
    const system = withQwenStructuredHint(provider, built.system, call.outputSchema); // qwen(DashScope)需把 key 骨架注入 prompt

    const invocationId = await this.recorder.start({
      ...this.baseStart(call, ctx, modelId, provider, inputHash, input, false),
      promptTemplate: prompt.length > 8000 ? prompt.slice(0, 8000) + '…[truncated]' : prompt,
      systemPrompt: system.length > 40000 ? system.slice(0, 40000) + '…[truncated]' : system,
    });

    yield { type: 'start', invocationId, modelId, promptVersion: call.promptVersion };

    try {
      // flash 对嵌套 schema 的**最终解析**偶发 "No object generated: could not parse"
      //   (partial 流没问题、是收尾那次校验碎)→ 跟 run 路径一样重试一次再兜底。
      //   重试会重新 stream 一轮 partial;前端按 id 全量替换,重流只是覆盖,无副作用。
      const MAX_STREAM_ATTEMPTS = 2;
      let finalObject: TOutput | undefined;
      let promptTokens = 0;
      let completionTokens = 0;
      let cachedInputTokens = 0;
      let reasoningTokens = 0;
      let providerMetadata: unknown = null;
      // 超时兜底(防流式永久挂起 → 前端一直空转显示"停止")。gemini 不关思考(见 run() 注释)。
      const callSignal = withTimeout(ctx.signal, this.requestTimeoutMs());
      const callStart = Date.now(); // 计时:TTFT(首个内容 partial)+ latency(总耗时)
      let firstPartialAt: number | undefined;
      for (let attempt = 1; attempt <= MAX_STREAM_ATTEMPTS; attempt++) {
        try {
          // eslint-disable-next-line @typescript-eslint/no-explicit-any
          const stream = streamObject<any>({ model, schema: call.outputSchema, system, prompt, abortSignal: callSignal });
          for await (const partial of stream.partialObjectStream) {
            if (firstPartialAt === undefined && partial && Object.keys(partial).length > 0) {
              firstPartialAt = Date.now(); // 第一个有内容的 partial = 首字
            }
            yield { type: 'partial', partial: partial as Partial<TOutput> };
          }
          // 等最终对象(解析失败在此抛)+ 计量(stream.object / usage 都是 Promise)
          finalObject = (await stream.object) as TOutput;
          const usage = await stream.usage;
          promptTokens = usage?.inputTokens ?? 0;
          completionTokens = usage?.outputTokens ?? 0;
          cachedInputTokens = (usage as { cachedInputTokens?: number } | undefined)?.cachedInputTokens ?? 0;
          reasoningTokens = (usage as { reasoningTokens?: number } | undefined)?.reasoningTokens ?? 0;
          providerMetadata = await (stream as { providerMetadata?: Promise<unknown> }).providerMetadata;
          break;
        } catch (streamErr) {
          const m = streamErr instanceof Error ? streamErr.message : String(streamErr);
          if (ctx.signal?.aborted) throw streamErr; // 客户端断连 → 别重试,抛到外层 abort 分支
          if (attempt < MAX_STREAM_ATTEMPTS) {
            this.logger.warn(`stream call=${call.callKey} 第 ${attempt} 次解析失败,重试: ${m}`);
            continue;
          }
          throw streamErr; // 重试耗尽 → 外层 catch 兜底
        }
      }
      const finalObj = finalObject as TOutput; // 循环要么 break(有值)要么 throw
      this.logger.log(
        `[usage stream] call=${call.callKey} promptTokens=${promptTokens} completionTokens=${completionTokens} ` +
          `providerMetadata=${JSON.stringify(providerMetadata ?? null)}`,
      );
      const costYuan = this.estimateCostYuan(modelId, promptTokens, completionTokens, cachedInputTokens);

      // safety gate
      if (call.safetyRules && call.safetyRules.length > 0) {
        try {
          this.safety.check(finalObj, call.safetyRules, {
            callKey: call.callKey,
            promptVersion: call.promptVersion,
            tenantId: ctx.tenantId,
          });
        } catch (err) {
          if (err instanceof SafetyGateRejectError && call.fallback) {
            const fb = call.fallback(input, `safety: ${err.message}`);
            await this.recorder.end(invocationId, {
              output: fb as Prisma.InputJsonValue,
              status: 'failed',
              errorMessage: `safety rejected, fallback used: ${err.message}`,
              promptTokens,
              completionTokens,
              totalTokens: promptTokens + completionTokens,
              cachedInputTokens,
              reasoningTokens,
              costYuan,
            });
            yield {
              type: 'done',
              output: fb,
              source: 'template_fallback',
              invocationId,
              promptTokens,
              completionTokens,
              costYuan,
              fallbackReason: `safety: ${err.message}`,
            };
            return;
          }
          throw err;
        }
      }

      await this.recorder.end(invocationId, {
        output: finalObj as Prisma.InputJsonValue,
        promptTokens,
        completionTokens,
        totalTokens: promptTokens + completionTokens,
        cachedInputTokens,
        reasoningTokens,
        costYuan,
        ttftMs: firstPartialAt !== undefined ? firstPartialAt - callStart : undefined,
        latencyMs: Date.now() - callStart,
        status: 'succeeded',
      });
      this.cache.set(inputHash, finalObj);

      yield {
        type: 'done',
        output: finalObj,
        source: 'agent',
        invocationId,
        promptTokens,
        completionTokens,
        costYuan,
      };
    } catch (err) {
      const errMsg = err instanceof Error ? err.message : String(err);
      // 客户端断连/取消 → 不兜底、不 yield done(消费方已 break),只记 aborted 后收尾
      if (ctx.signal?.aborted) {
        await this.recorder.end(invocationId, { status: 'failed', errorMessage: `aborted: ${errMsg}` });
        return;
      }
      this.logger.warn(`stream call=${call.callKey} 失败: ${errMsg}`);

      if (call.fallback) {
        const fb = call.fallback(input, `llm: ${errMsg}`);
        await this.recorder.end(invocationId, {
          output: fb as Prisma.InputJsonValue,
          status: 'failed',
          errorMessage: `fallback used: ${errMsg}`,
        });
        yield {
          type: 'done',
          output: fb,
          source: 'template_fallback',
          invocationId,
          promptTokens: 0,
          completionTokens: 0,
          costYuan: 0,
          fallbackReason: errMsg,
        };
        return;
      }

      await this.recorder.end(invocationId, { status: 'failed', errorMessage: errMsg });
      yield { type: 'error', message: errMsg, invocationId };
    }
  }

  /**
   * fallback 走模板兜底。
   *   - 有 fallback → 用,标 source=template_fallback,invocation status=succeeded(因为兜底了)
   *   - 没 fallback → 落 failed,抛错给上层(上层决定是否硬失败 / 显占位)
   */
  private async fallback<TInput, TOutput>(
    call: AiCall<TInput, TOutput>,
    input: TInput,
    _ctx: AiCallContext,
    invocationId: string,
    reason: string,
  ): Promise<AiCallResult<TOutput>> {
    if (!call.fallback) {
      await this.recorder.end(invocationId, { status: 'failed', errorMessage: reason });
      throw new Error(`call=${call.callKey} 失败且无 fallback: ${reason}`);
    }
    const output = call.fallback(input, reason);
    await this.recorder.end(invocationId, {
      output: output as Prisma.InputJsonValue,
      status: 'failed',
      errorMessage: `fallback used: ${reason}`,
    });
    return {
      output,
      invocationId,
      cacheHit: false,
      source: 'template_fallback',
      fallbackReason: reason,
      promptTokens: 0,
      completionTokens: 0,
      costYuan: 0,
    };
  }

  private baseStart<TInput, TOutput>(
    call: AiCall<TInput, TOutput>,
    ctx: AiCallContext,
    modelId: string,
    provider: string,
    inputHash: string,
    input: TInput,
    cacheHit: boolean,
  ) {
    return {
      hostId: ctx.hostId,
      tenantId: ctx.tenantId,
      kind: call.kind,
      callKey: call.callKey,
      promptVersion: call.promptVersion,
      modelProvider: provider,
      modelName: modelId,
      workflowRunId: ctx.workflowRunId,
      parentInvocationId: ctx.parentInvocationId,
      inputHash,
      cacheHit,
      evalMode: ctx.evalMode ?? 'production',
      goldenSetCaseId: ctx.goldenSetCaseId,
      inputSnapshot: input as unknown as Prisma.InputJsonValue,
      linkedPatientId: ctx.linkedPatientId,
      linkedPersonaId: ctx.linkedPersonaId,
      linkedPlanId: ctx.linkedPlanId,
    };
  }

  /**
   * 成本估算(人民币元,直接落 Decimal(12,6) 列)。
   *
   * 价格表从 config(env AI_PRICE_TABLE_JSON)读 — 调价改 env 重启即可,代码不动;
   * 历史 invocation 行的 costYuan 已快照入库,不会被新价污染。
   *
   * cachedInputTokens 由 SDK 从 usage.cachedInputTokens 取(DeepSeek prompt cache 命中部分);
   * 为 0 时退化到全 cache miss 上限估算。
   *
   * 同 prompt + 同输入连续调用时 cache hit 比例上升,实际成本明显低于纯 miss 价。
   */
  /** LLM 单次调用超时(毫秒),来自 config(env AI_REQUEST_TIMEOUT_SEC,默认 60s)。 */
  private requestTimeoutMs(): number {
    return this.config.get('ai', { infer: true }).requestTimeoutSec * 1000;
  }

  private estimateCostYuan(
    modelId: string,
    promptTokens: number,
    completionTokens: number,
    cachedInputTokens: number = 0,
  ): number {
    const priceTable = this.config.get('ai', { infer: true }).priceTable;
    const { yuan, priceMissing } = estimateCostYuan(
      priceTable,
      modelId,
      promptTokens,
      completionTokens,
      cachedInputTokens,
    );
    if (priceMissing) {
      /**
       * 🔴 **价目表里没有这个模型 —— 必须吭声**（2026-08-13）。
       * 原来这里静默回落到 `deepseek-v4-pro` 的价:换成 qwen 旗舰之后，
       * 成本会被**低报约四倍**，而报表上看不出任何异常。
       * ⚠️ 仍然回落(总比不记账好),但要留痕 —— 补价走
       *   `AI_PRICE_TABLE_JSON='{"<model>":{"inHit":X,"inMiss":Y,"out":Z}}'`,⛔ 不用改代码。
       */
      this.logger.warn(
        `价目表里没有 ${modelId} —— 本次成本按 deepseek-v4-pro 的价估算,数字**不准**。` +
          `补价:AI_PRICE_TABLE_JSON`,
      );
    }
    return yuan;
  }
}

/**
 * qwen(DashScope compatible-mode)的结构化输出补丁 —— 仅对 qwen 生效,其它 provider 原样返回。
 *
 * 背景(实测 2026-06,qwen3.7-max):DashScope 不强制 json_schema,模型会自己编 key(甚至拿中文 describe 当 key)
 *   → AI SDK 用 Zod 校验必 fail。修法:把 outputSchema 转成一份**显式英文 key 骨架**注入 system,
 *   告诉 qwen "只能用这些英文 key + 这个嵌套结构";配合 provider 端 response_format=json_object
 *   (见 ai-provider.service.ts),qwen 才稳定产出可校验的 JSON(实测 stable/standard/deep 三档均 100%)。
 *   骨架文案含 "JSON" 字样,顺带满足 DashScope json_object 对 messages 含 "json" 的硬性要求。
 */
/**
 * 给调用裹一道超时 —— 防 LLM 永久挂起(连接卡死 / 推理失控)导致 invocation 一直 running、前端一直转。
 * 超时触发时 callSignal abort,但 ctx.signal(客户端断连)未 abort → runner 走正常 catch → 兜底模板,不是 aborted 分支。
 */
function withTimeout(clientSignal: AbortSignal | undefined, ms: number): AbortSignal {
  const timeout = AbortSignal.timeout(ms);
  return clientSignal ? AbortSignal.any([clientSignal, timeout]) : timeout;
}

function withQwenStructuredHint(provider: string, system: string, schema: ZodSchema): string {
  if (provider !== 'qwen') return system;
  const skeleton = JSON.stringify(jsonSchemaToSkeleton(z.toJSONSchema(schema)));
  return (
    `${system}\n\n【输出格式·硬约束】必须输出一个 JSON 对象,**严格使用下面骨架里的英文 key 与嵌套结构**` +
    `(不要用中文 key、不要多余字段、不要 markdown 代码块);尖括号 <...> 处替换成实际内容:\n${skeleton}`
  );
}

/**
 * JSON Schema → 紧凑"示例骨架"(英文 key 保留、嵌套结构保留、说明降级为 <...> 值占位)。
 * 给 qwen 看 key 骨架比给它看完整 JSON Schema 更稳(避免它把 description 当 key)。
 */
function jsonSchemaToSkeleton(node: unknown): unknown {
  if (!node || typeof node !== 'object') return '<...>';
  const n = node as Record<string, unknown>;
  if (Array.isArray(n.enum)) return `<${(n.enum as unknown[]).join('|')}>`;
  if (n.type === 'array') return [jsonSchemaToSkeleton(n.items)];
  if (n.type === 'object') {
    const out: Record<string, unknown> = {};
    for (const [k, v] of Object.entries((n.properties as Record<string, unknown>) ?? {})) {
      out[k] = jsonSchemaToSkeleton(v);
    }
    return out;
  }
  if (n.type === 'boolean') return '<true|false>';
  if (n.type === 'integer' || n.type === 'number') {
    return `<数字${n.description ? '·' + String(n.description) : ''}>`;
  }
  return `<${n.description ? String(n.description) : '文本'}>`;
}
