import {
  Body,
  Controller,
  Get,
  Param,
  Post,
  Query,
  Res,
} from '@nestjs/common';
import type { Response } from 'express';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { ApiCode, Permission, PlanEventType, SubmitRecallFeedbackRequestSchema } from '@pac/types';
import { z } from 'zod';
import { RequirePermission } from '../../common/decorators/permissions.decorator';
import { BizError } from '../../common/errors/biz-error';
import { assertPlanClaimedBy } from '../plan/claim-guard';
import { recordPlanEvent } from '../plan/plan-event.recorder';
import {
  TenantScope,
  TenantScopeContext,
} from '../../common/decorators/tenant-scope.decorator';
import {
  CurrentUser,
  AuthenticatedUser,
} from '../../common/decorators/current-user.decorator';
import { PrismaService } from '../../prisma/prisma.service';
import { PlanScriptOrchestrator } from '../ai/orchestrators/plan-script.orchestrator';
import type { PlanScriptStreamEvent } from '../ai/orchestrators/plan-script.orchestrator';
import { PlanSummaryOrchestrator } from '../ai/orchestrators/plan-summary.orchestrator';
import { RecallSummaryOrchestrator } from '../ai/orchestrators/recall-summary.orchestrator';
import { PersonaSummaryOrchestrator } from '../ai/orchestrators/persona-summary.orchestrator';
import { RecallBriefOrchestrator } from '../ai/orchestrators/recall-brief.orchestrator';
import { PlanAggregateService } from './plan-aggregate.service';
import {
  renderAgentIdentity,
  resolveScriptAgent,
} from '../ai/calls/draft-plan-script/shared/agent-identity';
import type { ScriptAgentIdentity } from '../ai/calls/draft-plan-script/shared/agent-identity';

const ScriptFeedbackSchema = z.object({
  feedback: z.enum(['up', 'down']),
});

/**
 * PlansAggregateController — 生产路径的 plan 详情聚合 + AI 资产再生成端点。
 *
 * 端点(全 /pac/v1/plans/:id/...):
 *   GET  :id/full              详情聚合(plan+patient+profile+persona+chains+facts)
 *   POST :id/script:regenerate 同步重生成话术(测试用,等价 stream 但不流)
 *   GET  :id/script:stream     流式重生成话术(SSE)
 *   GET  :id/summary:stream    流式重生成 plan 摘要(SSE,1 次产 3 段)
 *
 * 跟 PlanController(同 /plans 前缀,strict Zod 的 CRUD)分工:
 *   - PlanController:list / detail / assign / recycle / executions(都走 strict Zod 契约)
 *   - 本控制器:聚合 + AI 资产相关(响应 shape 复杂,跳过 strict Zod 提速迭代)
 *
 * 放 PlanAggregateModule:避免 PlanModule ↔ PlanAggregateModule 循环依赖(PlanAggregateModule 已 import PlanModule/AiModule)。
 */
@ApiTags('plan')
@ApiBearerAuth('accessToken')
@Controller('plans')
export class PlansAggregateController {
  constructor(
    private readonly demo: PlanAggregateService,
    private readonly planScript: PlanScriptOrchestrator,
    private readonly planSummary: PlanSummaryOrchestrator,
    private readonly recallSummary: RecallSummaryOrchestrator,
    private readonly personaSummary: PersonaSummaryOrchestrator,
    private readonly recallBrief: RecallBriefOrchestrator,
    private readonly prisma: PrismaService,
  ) {}

  /**
   * 认领闸(见 plan/claim-guard.ts)—— 「未认领只能看,不能操作」。
   * 本控制器的写操作都是**详情页上的按钮**(重生成话术 / 打反馈),按钮就该受这道闸;
   * 只读端点(:id/full、recall-brief 等)不过闸 —— "看"始终开放。
   */
  private async assertClaimed(
    scope: TenantScopeContext,
    planId: string,
    userId: string,
  ): Promise<void> {
    const plan = await this.prisma.followupPlan.findFirst({
      where: { id: planId, hostId: scope.hostId, tenantId: scope.tenantId },
      select: { assigneeUserId: true },
    });
    if (!plan) throw new BizError(ApiCode.PLAN_NOT_FOUND, `Plan ${planId} not found`);
    assertPlanClaimedBy(plan, userId);
  }

  // ─────────────────────────────────────────────
  // 详情聚合
  // ─────────────────────────────────────────────

  @Get(':id/full')
  @RequirePermission(Permission.PLAN_VIEW_OWN)
  @ApiOperation({
    summary: 'Plan 详情聚合(plan+patient+profile+persona+chains+facts)',
    description: '前端 (app)/plans/[planId] 用本端点;PlanController.detail 走 strict Zod 给外部。',
  })
  getFull(
    @TenantScope() scope: TenantScopeContext,
    @CurrentUser() user: AuthenticatedUser,
    @Param('id') planId: string,
  ) {
    // ⭐ 话术里的【回访客服】按**当前登录人**回填(缓存是 per-plan、池子共享,不能烤人名)
    return this.demo.getPlanDetailByPlanId(scope, planId, resolveScriptAgent(user));
  }

  // 回访历史「一句话摘要」get-or-generate(详情页回访历史卡进卡片即调:
  //   有 ready 摘要秒回;没有则当场跑 AiCall 生成存库再回;无回访记录 → status='empty')。
  @Get(':id/recall-summary')
  @RequirePermission(Permission.PLAN_VIEW_OWN)
  @ApiOperation({ summary: '回访历史一句话摘要(有则取、无则当场生成)' })
  getRecallSummary(@TenantScope() scope: TenantScopeContext, @Param('id') planId: string) {
    return this.recallSummary.getOrGenerate(scope, planId);
  }

  // 画像标签「一句话重点」get-or-generate(详情页「画像标签」卡进卡片即调:
  //   有 ready 摘要秒回;没有则当场跑 AiCall 生成存库再回;无 persona/feature → status='empty')。
  @Get(':id/persona-summary')
  @RequirePermission(Permission.PLAN_VIEW_OWN)
  @ApiOperation({ summary: '画像标签一句话重点(有则取、无则当场生成)' })
  getPersonaSummary(@TenantScope() scope: TenantScopeContext, @Param('id') planId: string) {
    return this.personaSummary.getOrGenerate(scope, planId);
  }

  // 本次召回「一句话简报」get-or-generate(详情页「参考话术」标题下;输入=召回原因+历史治疗+画像,
  //   回答 患者是谁 / 帮其解决什么问题 / 邀约到诊做什么 三问;无召回原因 → status='empty')。
  @Get(':id/recall-brief')
  @RequirePermission(Permission.PLAN_VIEW_OWN)
  @ApiOperation({ summary: '本次召回一句话简报(有则取、无则当场生成)' })
  getRecallBrief(@TenantScope() scope: TenantScopeContext, @Param('id') planId: string) {
    return this.recallBrief.getOrGenerate(scope, planId);
  }

  // ─────────────────────────────────────────────
  // 话术 — 同步重生成 / 流式重生成
  // ─────────────────────────────────────────────

  @Post(':id/script:regenerate')
  @RequirePermission(Permission.PLAN_VIEW_OWN)
  @ApiOperation({ summary: '重新生成 plan 话术(测试 / 调试用,输入不变)' })
  async regenerateScript(
    @TenantScope() scope: TenantScopeContext,
    @CurrentUser() user: AuthenticatedUser,
    @Param('id') planId: string,
    @Query('bustCache') bustCache?: string,
    @Query('model') model?: string,
    @Query('tier') tier?: string,
    @Body() body?: { dryRun?: boolean },
  ) {
    // 认领闸:重生成要真花 AI 钱,不该让没认领的人点
    await this.assertClaimed(scope, planId, user.sub);
    const result = await this.planScript.generate(planId, {
      bustCache: bustCache === 'true' || bustCache === '1',
      modelIdOverride: model,
      tier: parseScriptTier(tier),
      dryRun: body?.dryRun ?? false,
    });
    const agent = resolveScriptAgent(user);
    return {
      planId: result.planId,
      planScriptId: result.planScriptId,
      agentInvocationId: result.agentInvocationId,
      source: result.source,
      cacheHit: result.cacheHit,
      costYuan: result.costYuan,
      // 落库的是占位符正文,返回给调用方的是回填后的(structured 保持原始,便于调试看 LLM 真实输出)
      content: renderAgentIdentity(result.content, agent),
      structured: result.structured,
    };
  }

  /**
   * 流式重生成话术 — SSE。
   *
   * 事件 data 是 JSON,见 PlanScriptStreamEvent:
   *   start    {invocationId, modelId, promptVersion}
   *   partial  {structured, sections}        ← 多次
   *   done     {planScriptId, source, content, sections, costYuan}
   *   error    {message}
   *
   * 用裸 Response 而不是 @Sse() Observable — 后者把 finalize 时机藏起来。
   */
  @Get(':id/script:stream')
  @RequirePermission(Permission.PLAN_VIEW_OWN)
  @ApiOperation({ summary: '流式重新生成 plan 话术(SSE)' })
  async streamScript(
    @TenantScope() scope: TenantScopeContext,
    @CurrentUser() user: AuthenticatedUser,
    @Param('id') planId: string,
    @Query('model') model: string | undefined,
    @Query('tier') tier: string | undefined,
    @Res() res: Response,
  ): Promise<void> {
    // 认领闸 —— 在开 SSE 之前抛,让错误走正常 JSON envelope 而不是流里的 error 事件
    await this.assertClaimed(scope, planId, user.sub);
    await this.pipeSse(res, (signal) =>
      renderAgentInStream(
        this.planScript.generateStream(planId, {
          modelIdOverride: model,
          tier: parseScriptTier(tier),
          signal,
        }),
        resolveScriptAgent(user),
      ),
    );
  }

  // ─────────────────────────────────────────────
  // Summary — 流式重生成(1 次产 3 段:onePage / medicalRecord / treatmentChain)
  // ─────────────────────────────────────────────

  // ─────────────────────────────────────────────
  // 话术 / 摘要 thumbs up/down 反馈("本段是否好用")
  // ─────────────────────────────────────────────

  /**
   * 反馈某 plan 当前话术(对应 plan_scripts.agent_invocation_id)up/down。
   * 同一 invocation 反复点会覆盖(再点 down 覆盖 up,以最新为准)。
   * 没找到 planScript / agentInvocationId → 404(避免无 invocation 的 plan 接受反馈)。
   *
   * 后续可加:summary feedback(对称端点)/ 聚合 dashboard(up/down 比 + 命中 fallback 时禁止评)。
   */
  @Post(':id/script-feedback')
  @RequirePermission(Permission.PLAN_VIEW_OWN)
  @ApiOperation({ summary: '对当前话术 thumbs up/down 反馈("本段是否好用")' })
  async submitScriptFeedback(
    @TenantScope() scope: TenantScopeContext,
    @CurrentUser() user: AuthenticatedUser,
    @Param('id') planId: string,
    @Body() body: unknown,
  ) {
    await this.assertClaimed(scope, planId, user.sub); // 认领闸
    const { feedback } = ScriptFeedbackSchema.parse(body);
    const script = await this.prisma.planScript.findFirst({
      where: { planId, hostId: scope.hostId, tenantId: scope.tenantId },
    });
    if (!script?.agentInvocationId) {
      return { ok: false as const, reason: 'no_invocation' };
    }
    await this.prisma.agentInvocation.update({
      where: { id: script.agentInvocationId },
      data: {
        userFeedback: feedback,
        userFeedbackAt: new Date(),
        userFeedbackBy: user.sub,
      },
    });
    return { ok: true as const, invocationId: script.agentInvocationId, feedback };
  }

  @Post(':id/view')
  @RequirePermission(Permission.PLAN_VIEW_OWN)
  @ApiOperation({
    summary: '上报 plan 详情页浏览(PV/UV 埋点)—— 每进入一次记一条',
  })
  async reportPlanView(
    @TenantScope() scope: TenantScopeContext,
    @CurrentUser() user: AuthenticatedUser,
    @Param('id') planId: string,
  ) {
    // ⚠️ **不加认领闸**(区别于 recall-feedback / 执行结果):浏览发生在认领**之前**,
    //    池子里任何一条都能点开看。加了闸这个埋点就只剩已认领的,PV/UV 直接废掉。
    const plan = await this.prisma.followupPlan.findFirst({
      where: { id: planId, hostId: scope.hostId, tenantId: scope.tenantId },
      select: { id: true, hostId: true, tenantId: true, patientId: true },
    });
    // 静默返回 not_found:埋点是**尽力而为**的旁路,不能因为它失败而影响页面
    if (!plan) return { ok: false as const, reason: 'not_found' };
    await recordPlanEvent(this.prisma, {
      hostId: plan.hostId,
      tenantId: plan.tenantId,
      planId: plan.id,
      patientId: plan.patientId,
      event: PlanEventType.VIEW,
      actorUserId: user.sub,
    });
    return { ok: true as const };
  }

  @Post(':id/recall-feedback')
  @RequirePermission(Permission.PLAN_VIEW_OWN)
  @ApiOperation({
    summary: '召回反馈(plan 级)— 对"这条召回准不准"打拇指 + 文字(down 弹窗收集)',
  })
  async submitRecallFeedback(
    @TenantScope() scope: TenantScopeContext,
    @CurrentUser() user: AuthenticatedUser,
    @Param('id') planId: string,
    @Body() body: unknown,
  ) {
    await this.assertClaimed(scope, planId, user.sub); // 认领闸
    const { feedback, note } = SubmitRecallFeedbackRequestSchema.parse(body);
    const plan = await this.prisma.followupPlan.findFirst({
      where: { id: planId, hostId: scope.hostId, tenantId: scope.tenantId },
      select: { id: true, hostId: true, tenantId: true, patientId: true },
    });
    if (!plan) return { ok: false as const, reason: 'not_found' };
    // 反馈是对 plan 本身的判断 → 直接写 followup_plans(正交于执行,不进事实层)
    await this.prisma.$transaction(async (tx) => {
      await tx.followupPlan.update({
        where: { id: plan.id },
        data: {
          recallFeedback: feedback,
          recallFeedbackNote: note?.trim() ? note.trim() : null,
        },
      });
      // ⭐ 同时落事件账本:followup_plans.recall_feedback 是**就地覆盖**的,
      //   客服先点 👍 后改 👎(或反复评)会把前一次冲掉。准确度统计要的是**全部判定**,
      //   只看最终值会低估分子,也看不出"改判"这种强信号。
      await recordPlanEvent(tx, {
        hostId: plan.hostId,
        tenantId: plan.tenantId,
        planId: plan.id,
        patientId: plan.patientId,
        event: PlanEventType.FEEDBACK,
        actorUserId: user.sub,
        reason: feedback, // 'up' | 'down' —— 立柱,统计直接按它过滤
        details: note?.trim() ? { note: note.trim() } : null,
      });
    });
    return { ok: true as const, feedback };
  }

  @Get(':id/summary:stream')
  @RequirePermission(Permission.PLAN_VIEW_OWN)
  @ApiOperation({ summary: '流式重新生成 plan 摘要(SSE,1 次产 3 段)' })
  async streamSummary(
    @Param('id') planId: string,
    @Query('model') model: string | undefined,
    @Res() res: Response,
  ): Promise<void> {
    // 摘要档为单次调用,暂不透传 signal(controller 断连仍会停心跳/停写);如需取消在途再透传
    await this.pipeSse(res, () => this.planSummary.generateStream(planId, { modelIdOverride: model }));
  }

  // ─────────────────────────────────────────────
  // 内部:把 AsyncGenerator → SSE 写出(共用,两个流式端点都走)
  // ─────────────────────────────────────────────

  private async pipeSse<T extends { type: string }>(
    res: Response,
    makeStream: (signal: AbortSignal) => AsyncIterable<T>,
  ): Promise<void> {
    res.setHeader('Content-Type', 'text/event-stream; charset=utf-8');
    res.setHeader('Cache-Control', 'no-cache, no-transform');
    res.setHeader('Connection', 'keep-alive');
    res.setHeader('X-Accel-Buffering', 'no'); // 关 nginx buffering
    res.flushHeaders?.();

    // ⭐ 客户端断连(关页面/点停止/网络断)→ abort:取消在途 LLM(尤其深度 3-4 步,别白烧)
    //    + 停心跳、停写,避免往已关闭 socket 写。
    const ac = new AbortController();
    res.on('close', () => ac.abort());

    // 心跳防代理断连(每 15s 一个注释行);socket 已关则不写
    const heartbeat = setInterval(() => {
      if (!res.writableEnded) res.write(': hb\n\n');
    }, 15_000);

    try {
      for await (const evt of makeStream(ac.signal)) {
        if (ac.signal.aborted) break;
        res.write(`event: ${evt.type}\n`);
        res.write(`data: ${JSON.stringify(evt)}\n\n`);
        (res as Response & { flush?: () => void }).flush?.();
        if (evt.type === 'done' || evt.type === 'error') break;
      }
    } catch (err) {
      if (!ac.signal.aborted && !res.writableEnded) {
        const message = err instanceof Error ? err.message : String(err);
        res.write(`event: error\ndata: ${JSON.stringify({ type: 'error', message })}\n\n`);
      }
    } finally {
      clearInterval(heartbeat);
      if (!res.writableEnded) res.end();
    }
  }
}

/**
 * SSE 流的【回访客服】回填 —— 落库存占位符,推给前端的每一帧都换成当前登录客服。
 *
 * 只动 `partial` / `done` 的展示字段(sections.markdown / content);`structured` 保持 LLM 原始输出
 * 不动(调试时要能看到模型到底吐了什么)。partial 是**全量快照**(非增量 delta),所以逐帧替换安全,
 * 不存在占位符被切在两帧之间的问题。
 */
async function* renderAgentInStream(
  stream: AsyncIterable<PlanScriptStreamEvent>,
  agent: ScriptAgentIdentity,
): AsyncGenerator<PlanScriptStreamEvent, void, void> {
  for await (const evt of stream) {
    if (evt.type === 'partial' || evt.type === 'done') {
      const sections = evt.sections.map((s) => ({
        ...s,
        markdown: renderAgentIdentity(s.markdown, agent),
      }));
      yield evt.type === 'done'
        ? { ...evt, sections, content: renderAgentIdentity(evt.content, agent) }
        : { ...evt, sections };
      continue;
    }
    yield evt;
  }
}

/** 投入档 query 解析:只接受白名单,非法/缺省 → undefined(orchestrator 默认 stable) */
function parseScriptTier(tier: string | undefined): 'stable' | 'standard' | 'deep' | undefined {
  return tier === 'stable' || tier === 'standard' || tier === 'deep' ? tier : undefined;
}
