import { Body, Controller, Logger, Post, Req, Res, UploadedFile, UseInterceptors } from '@nestjs/common';
import { FileInterceptor } from '@nestjs/platform-express';
import type { Request, Response } from 'express';
import { ApiBearerAuth, ApiConsumes, ApiOperation, ApiTags } from '@nestjs/swagger';
import type { ModelMessage } from 'ai';
import { SheetSnapshotSchema } from '@pac/types';
import { AssistantService, MAX_TOOL_STEPS } from './assistant.service';
import { TranscribeService } from './transcribe.service';
import { CurrentUser, type AuthenticatedUser } from '../../common/decorators/current-user.decorator';
import { TenantScope } from '../../common/decorators/tenant-scope.decorator';
import type { TenantScopeContext } from '../../common/decorators/tenant-scope.decorator';


/// multer 内存模式的最小文件形状(不引 @types/multer)
interface UploadedAudio {
  buffer: Buffer;
  originalname: string;
  mimetype: string;
}

/** 只认标准 uuid —— conversationId 直接进 `workflow_run_id`（uuid 列），⛔ 别让脏值打到数据库。 */
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;

interface ChatBody {
  messages: ModelMessage[];
  model?: string;
  /**
   * 这一串对话的 id —— 前端在**开始一段新对话时**现生成，此后每轮原样带上。
   *
   * 🔴 它是「哪几轮属于同一次对话」的**唯一可靠依据**。此前只能靠 `turnNo` 归 1 反推，
   *   而那是启发式：两位主管并发聊天时行是交错的，前端一旦裁剪历史 turnNo 也会错位。
   * ⚠️ 前端可改的入参 —— 只用于**归组**，⛔ 不参与任何鉴权或取数。
   *   非 uuid 一律丢弃（服务端另生成），⛔ 不让它成为写库的注入面。
   */
  conversationId?: string;
  /**
   * 主管当前正看着哪家诊所 —— 作 `propose_assignment` 的诊所兜底。
   *
   * 🔴 移交那句话里**没有诊所**（「帮我给「拔牙 · 3 年以上」这批患者出一份分配方案」），
   *   模型无从得知；不传就回落 `scope.clinicIds[0]`，多诊所主管在第二家的矩阵上点一格，
   *   确认单会按**第一家**出 —— 人数对不上而且不报错。
   * ⚠️ 仍然要过越权闸（`resolveClinicId`）：它是**前端可改**的入参，⛔ 不能当可信来源。
   */
  activeClinicId?: string;
  /**
   * 眼前那张**还没确认**的确认单，此刻的样子 —— 模型用 `get_current_sheet` 取。
   *
   * 🔴 为什么由前端捎来：确认之前，改派 / 移出 / 时限 / 福利**只存在于卡片组件里**，
   *   服务端手上只有最初那一版提案（见 `SheetSnapshotSchema` 上那段）。
   * ⚠️ 与 `activeClinicId` 同一条纪律：前端可改的入参 ——
   *   它只用来**告诉模型现在什么样**，⛔ 一个字都不许拿去写库。
   *   （真正落库走 `POST /plans/assignments`，那条路自己带全量数据。）
   * ⚠️ 用 zod 现场解析而不是直接透传：字段少一个模型就会读到 `undefined` 并当成 0。
   */
  sheetState?: unknown;
  /**
   * 详情页当前开着的那位患者 —— 与 `activeClinicId` 同一条旁路。
   *
   * 🔴 他在那一屏说「这通电话怎么开口」「这个患者为什么被召回」时**句子里没有名字**，
   *   模型无从知道是谁（界面只拿姓名去预填例句文字，从没发给过它）。
   * ⚠️ 它只作**指代的默认落点**告诉模型，⛔ **不注入到工具参数** ——
   *   注入等于把"查另一位患者"这条路堵死，而他随时会问别人。
   * ⚠️ 前端可改的入参：越权由各工具自己的 `assertPatientInScope` 挡，
   *   ⛔ 不因为"是我们自己发的"就当可信。
   */
  activePatientId?: string;
}

/**
 * 从「正在流式生成的工具入参 JSON」里增量提取 html 字段值(用于 artifact 实时渲染)。
 * 入参形如 {"title":"...","html":"<div>...   ← 还没结束。容忍末尾不完整的转义,返回已解出的部分。
 */
function extractHtmlField(jsonText: string): string | null {
  const m = jsonText.match(/"html"\s*:\s*"/);
  if (!m || m.index === undefined) return null;
  const s = jsonText.slice(m.index + m[0].length);
  const esc: Record<string, string> = {
    n: '\n', t: '\t', r: '\r', '"': '"', '\\': '\\', '/': '/', b: '\b', f: '\f',
  };
  let out = '';
  for (let i = 0; i < s.length; i++) {
    const c = s[i];
    if (c === '\\') {
      const n = s[i + 1];
      if (n === undefined) break; // 末尾半截转义 → 停
      if (n === 'u') {
        if (i + 6 > s.length) break;
        out += String.fromCharCode(parseInt(s.slice(i + 2, i + 6), 16));
        i += 5;
        continue;
      }
      out += esc[n] ?? n;
      i += 1;
      continue;
    }
    if (c === '"') break; // html 值结束
    out += c;
  }
  return out;
}

/**
 * AssistantController — "外部 agent" 模拟器聊天端点(SSE 流式)。
 *
 * POST /pac/v1/assistant/chat  —— 普通 JWT 鉴权(全局 guard);转发用户 token 给 MCP,
 * 工具在该用户 tenant scope 下执行。把 streamText 的 fullStream 部件转成 SSE 事件给前端:
 *   { type: 'text', text }            文本增量
 *   { type: 'tool_call', tool, args } 模型发起工具调用(前端可视化"调了哪个 PAC 工具")
 *   { type: 'tool_result', tool, result }
 *   { type: 'error', error } / { type: 'done' }
 */
@ApiTags('assistant')
@ApiBearerAuth('accessToken')
@Controller('assistant')
export class AssistantController {
  private readonly logger = new Logger(AssistantController.name);

  constructor(
    private readonly assistant: AssistantService,
    private readonly transcriber: TranscribeService,
  ) {}

  /** 语音听写 → 文字(SenseVoice 自部署,PII 不出内网)。multipart 字段名 file。 */
  @Post('transcribe')
  @ApiConsumes('multipart/form-data')
  @ApiOperation({ summary: '语音转文字(听写)— 浏览器录音上传,返回 { text }' })
  @UseInterceptors(FileInterceptor('file', { limits: { fileSize: 15 * 1024 * 1024 } }))
  async transcribe(@UploadedFile() file: UploadedAudio | undefined): Promise<{ text: string }> {
    const r = await this.transcriber.transcribe(
      file?.buffer ?? Buffer.alloc(0),
      file?.originalname ?? 'audio.webm',
      file?.mimetype ?? 'audio/webm',
    );
    return { text: r.text };
  }

  /** 桌宠发言:输入一段环境观察,流式吐一句 ≤30 字的台词(无工具,极轻)。 */
  @Post('pet-say')
  @ApiOperation({ summary: '桌宠环境观察发言(SSE,简短一句)' })
  async petSay(
    @Req() req: Request,
    @Res() res: Response,
    @Body() body: { observation?: string },
  ): Promise<void> {
    res.setHeader('Content-Type', 'text/event-stream');
    res.setHeader('Cache-Control', 'no-cache, no-transform');
    res.setHeader('Connection', 'keep-alive');
    res.flushHeaders?.();
    const send = (event: Record<string, unknown>): void => {
      res.write(`data: ${JSON.stringify(event)}\n\n`);
    };
    const ac = new AbortController();
    req.on('close', () => ac.abort());
    try {
      const r = this.assistant.petSay({
        observation: String(body.observation ?? '').slice(0, 400), // 观察必须简短,后端兜底截断
        abortSignal: ac.signal,
      });
      for await (const text of r.textStream) {
        if (text) send({ type: 'text', text });
      }
      send({ type: 'done' });
    } catch (err) {
      send({ type: 'error', error: err instanceof Error ? err.message : String(err) });
    } finally {
      res.end();
    }
  }

  @Post('chat')
  @ApiOperation({ summary: '助手对话(SSE)— 模型自主调 PAC MCP 工具' })
  async chat(
    @Req() req: Request,
    @Res() res: Response,
    @Body() body: ChatBody,
    @CurrentUser() user: AuthenticatedUser,
    @TenantScope() scope: TenantScopeContext,
  ): Promise<void> {
    const token = (req.headers['authorization'] ?? '').replace(/^Bearer\s+/i, '');

    res.setHeader('Content-Type', 'text/event-stream');
    res.setHeader('Cache-Control', 'no-cache, no-transform');
    res.setHeader('Connection', 'keep-alive');
    res.flushHeaders?.();

    const send = (event: Record<string, unknown>): void => {
      res.write(`data: ${JSON.stringify(event)}\n\n`);
    };

    const ac = new AbortController();
    req.on('close', () => ac.abort());

    // 流式 artifact:render_artifact 的入参(HTML)逐字生成,边生成边推 → 前端实时"长出"卡片,
    // 不必等整段(~30s)生成完。按 toolCallId 累积 JSON 入参,增量提取 html 字段,250ms 节流发送。
    const artToolIds = new Set<string>();
    const artBuf = new Map<string, string>();
    const artLastSent = new Map<string, number>();

    try {
      const result = await this.assistant.chat({
        userToken: token,
        modelId: body.model,
        messages: body.messages ?? [],
        // 只用于 MCP 工具清单的缓存分桶(见 mcpCapabilityKey)——
        // 不传的话主管和客服会共用一份缓存,谁先进来谁的清单被全员复用。
        permissions: user.permissions,
        // ⭐ 侧信道:本地工具把确认单肥载荷直接推前端,不经模型上下文(见 AssistantChatInput)
        onSideEvent: send,
        scope,
        // ⭐ 角色层与现场层由服务端按权限装配(见 assistant-prompts 的分层)——
        //   ⚠️ 控制器只**供料**,⛔ 不在这里拼提示词:企微那条路也走同一个装配。
        // ⭐ 主管当前看的诊所 —— propose_assignment 的诊所兜底(见 DTO 上那段)
        ...(body.activeClinicId ? { activeClinicId: body.activeClinicId } : {}),
        ...(UUID_RE.test(body.conversationId ?? '') ? { conversationId: body.conversationId } : {}),
        // ⚠️ 解不出来就当没有 —— ⛔ 别把半份快照喂给模型(缺的字段会被读成 0)
        ...(typeof body.activePatientId === 'string' && body.activePatientId
          ? { activePatientId: body.activePatientId }
          : {}),
        ...(SheetSnapshotSchema.safeParse(body.sheetState).success
          ? { sheetState: SheetSnapshotSchema.parse(body.sheetState) }
          : {}),
        abortSignal: ac.signal,
      });

      for await (const part of result.fullStream) {
        const p = part as Record<string, unknown> & { type: string };
        switch (p.type) {
          case 'text-delta':
          case 'text':
            send({ type: 'text', text: (p.text as string) ?? (p.delta as string) ?? '' });
            break;
          /**
           * ⭐ 思考也转发 —— 它跟正文走**同一条流**，按到达顺序穿插到消息里。
           *
           * 🔴 为什么值得摆出来：2026-08-14 裸台实测，模型"为什么这么调工具"全写在这儿 ——
           *   「注意：show_guidance **一次只开放一件**」这句话直接指出了当轮只调一次的原因，
           *   而它是**读了我们的工具描述之后**的结论，不是模型的毛病。
           *   ⇒ 没有它，归因只能靠猜，而猜的方向大概率是"模型不行"。
           * ⚠️ 它是模型输出的一部分，最后随 messages 原样回传（DeepSeek 文档明确要求
           *   思考内容参与后续轮次的上下文拼接）—— ⛔ 别在这里过滤掉。
           */
          case 'reasoning-delta':
            send({ type: 'reasoning', text: (p.text as string) ?? '' });
            break;
          case 'tool-input-start':
            if (p.toolName === 'render_artifact') artToolIds.add(String(p.id));
            break;
          case 'tool-input-delta': {
            const id = String(p.id);
            if (!artToolIds.has(id)) break;
            const acc = (artBuf.get(id) ?? '') + String(p.delta ?? '');
            artBuf.set(id, acc);
            const now = Date.now();
            if (now - (artLastSent.get(id) ?? 0) > 250) {
              artLastSent.set(id, now);
              const html = extractHtmlField(acc);
              if (html) send({ type: 'artifact_html', id, html });
            }
            break;
          }
          case 'tool-call':
            send({ type: 'tool_call', id: p.toolCallId, tool: p.toolName, args: p.input });
            break;
          case 'tool-result':
            send({ type: 'tool_result', id: p.toolCallId, tool: p.toolName, result: p.output });
            break;
          case 'tool-error':
            // 工具 execute 抛错(如越权 patientId)→ SDK 发 tool-error 分片(流不中断,模型会自纠)。
            // 必须转发,否则前端该步骤永远停在 running(右侧转圈不停)。
            send({
              type: 'tool_error',
              id: p.toolCallId,
              tool: p.toolName,
              error: p.error instanceof Error ? p.error.message : String(p.error),
            });
            break;
          case 'finish':
            /**
             * 🔴 **no silent cap** —— `stopWhen(MAX_TOOL_STEPS)` 到顶时，SDK 以
             * `finishReason='tool-calls'` 收尾：模型还想继续调工具，被截断了。
             *
             * 不报出来的后果很隐蔽：它可能停在「我先查一下」之后就没了下文，
             * 而主管以为查完了。⚠️ 这里只**陈述事实**（本轮到上限了），
             * ⛔ 不替模型补话、⛔ 不自动续跑 —— 续不续是用户的决定。
             */
            if (p.finishReason === 'tool-calls') {
              this.logger.warn(
                `助手单轮工具步数到顶(${MAX_TOOL_STEPS})，模型仍想继续调用 —— 本轮被截断`,
              );
              send({ type: 'step_limit', limit: MAX_TOOL_STEPS });
            }
            break;
          case 'error':
            send({ type: 'error', error: String(p.error) });
            break;
          default:
            break;
        }
      }
      send({ type: 'done' });
    } catch (err) {
      send({ type: 'error', error: err instanceof Error ? err.message : String(err) });
    } finally {
      res.end();
    }
  }
}
