import { Body, Controller, Get, Param, Post, Query } from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { ZodResponse } from 'nestjs-zod';
import { Permission, parseAnchorMode } from '@pac/types';
import { RequirePermission } from '../../common/decorators/permissions.decorator';
import { TenantScope } from '../../common/decorators/tenant-scope.decorator';
import type { TenantScopeContext } from '../../common/decorators/tenant-scope.decorator';
import { resolveClinicId } from '../../common/decorators/resolve-clinic-id';
import { CurrentUser } from '../../common/decorators/current-user.decorator';
import type { AuthenticatedUser } from '../../common/decorators/current-user.decorator';
import { PlanAssignmentService } from './plan-assignment.service';
import { AgentRosterService } from './agent-roster.service';
import { AssignmentProposalService } from './assignment-proposal.service';
import {
  CreateAssignmentRequestDto,
  CreateAssignmentResponseDto,
  ListAssignmentsResponseDto,
  AssignmentDetailResponseDto,
  ListAgentsResponseDto,
  AgentWorkloadResponseDto,
  RefillProposalRequestDto,
  RevokeAssignmentResponseDto,
  SetAssignmentBenefitRequestDto,
  SetAssignmentBenefitResponseDto,
} from './dto/plan-assignment.dto';

/**
 * 批次分配端点 —— 门诊经理专用。
 *
 * ⛔ **刻意不改** `POST /plans/:id/assign` 与 `/plans/:id/recycle`:
 *   前者维持 PLAN_ASSIGN(认领语义,staff 也有,前端虽撤了入口但后端保留 —— T12/T16),
 *   后者维持 PLAN_RECYCLE(退回是客服路径)。
 *   把批次能力塞进那两个端点会让"客服自助"与"主管派单"两条路的权限纠缠在一起。
 *
 * 路由挂在 `plans/assignments` 而不是顶层 `plan-assignments`:
 *   它是 plan 的从属资源,跟 `plans/:id/executions` 同一层级心智。
 */
@ApiTags('plan-assignments')
@ApiBearerAuth('accessToken')
@Controller('plans/assignments')
export class AssignmentController {
  constructor(
    private readonly assignments: PlanAssignmentService,
    private readonly roster: AgentRosterService,
    private readonly proposals: AssignmentProposalService,
  ) {}

  /**
   * 客服名册 + 在手负载 —— **一个端点**,不拆成两个。
   * 分配问「还能吃多少」、跟踪问「压了多少」,是同一份数据的两种读法;
   * 拆开必然口径漂移(一个算 assigned、一个算 assigned+active),而漂了不报错。
   *
   * 完整路径 **`GET /pac/v1/plans/assignments/agents`**(控制器前缀是 `plans/assignments`)。
   * ⚠️ 别写成 `/plans/agents` —— 那个路径归 PlanController,会被它的裸 `@Get(':id')`
   *   当成 planId='agents',报出来是 Prisma 的 uuid 解析错,看不出是路由问题(实测踩过)。
   * ⚠️ 本方法必须声明在下面 `@Get(':id')` **之前**:同一控制器内 Nest 按声明顺序匹配,
   *   放后面 'agents' 会被当成 assignmentId。
   */
  @Get('agents')
  @RequirePermission(Permission.PLAN_DISPATCH)
  @ZodResponse({ status: 200, type: ListAgentsResponseDto })
  @ApiOperation({ summary: '在岗客服名册 + 在手负载(近 N 月有回访记录者;名册外亦可指定)' })
  async agents(
    @TenantScope() scope: TenantScopeContext,
    @Query('clinicId') clinicId: string,
    @Query('months') months?: string,
    @Query('include') include?: string,
  ) {
    // 🔴 越权闸:名册是**员工姓名**。⛔ 别只信前端传对 —— 查询串是用户可改的(见 resolveClinicId)
    return this.roster.list(scope, resolveClinicId(scope, clinicId), {
      months: months ? Number(months) : undefined,
      // 主管显式点名的人即使不在名册也要能查到 —— 名册是建议不是白名单
      extraUserIds: include ? include.split(',').filter(Boolean) : undefined,
    });
  }

  /**
   * 团队现在什么状态 —— 主管工作台右栏。
   *
   * ⚠️ 必须声明在 `@Get(':id')` **之前**(同一控制器按声明顺序匹配),
   *   放后面 'workload' 会被当成 assignmentId。
   * ⚠️ 一张表混着两种时间性:在手/超期是**此刻**,完成/退回/没动在**窗口内** ——
   *   响应里的 `note` 已经写好这句,界面照抄,⛔ 别自己另写一版。
   */
  @Get('workload')
  @RequirePermission(Permission.PLAN_DISPATCH)
  @ZodResponse({ status: 200, type: AgentWorkloadResponseDto })
  @ApiOperation({ summary: '团队负载:每人 在手/超期(此刻)+ 完成/退回/没动(窗口内)' })
  async workload(
    @TenantScope() scope: TenantScopeContext,
    @Query('clinicId') clinicId: string,
    @Query('days') days?: string,
  ) {
    // 🔴 越权闸 —— 2026-08-10 实测:朝阳公园的主管带别家 id 请求,拿到了那家 26 人的姓名与负载
    return this.assignments.workload(scope, resolveClinicId(scope, clinicId), days ? Number(days) : 7);
  }

  @Post()
  @RequirePermission(Permission.PLAN_DISPATCH)
  @ZodResponse({ status: 201, type: CreateAssignmentResponseDto })
  @ApiOperation({
    summary: '确认分配 —— 主管在确认单上点确认时调用(整条生产线唯一的写动作)',
    description:
      '幂等:requestId 由服务端在生成确认单时铸造,重复提交返回同一批次 + duplicate:true。' +
      '部分计划在确认期间被他人认领时其余照落,逐条在 skipped 里说明原因;一条都落不上则不建批次。',
  })
  async create(
    @TenantScope() scope: TenantScopeContext,
    @CurrentUser() user: AuthenticatedUser,
    @Body() dto: CreateAssignmentRequestDto,
  ) {
    return this.assignments.create(
      scope,
      { userId: user.sub, permissions: user.permissions },
      dto,
    );
  }

  @Get()
  @RequirePermission(Permission.PLAN_DISPATCH)
  @ZodResponse({ status: 200, type: ListAssignmentsResponseDto })
  @ApiOperation({ summary: '我分的那些批 —— 批次列表 + 每批汇总(分了/在手/已退回/涉及几人)' })
  async list(
    @TenantScope() scope: TenantScopeContext,
    @CurrentUser() user: AuthenticatedUser,
    @Query('mine') mine?: string,
    @Query('limit') limit?: string,
    /// 游标 = 上一页最后一条的 createdAt(ISO)。⛔ 不是 offset —— 批次一直在新增,
    /// offset 翻页会重复或漏行(见 service 里 list 的注释)
    @Query('before') before?: string,
  ) {
    // mine=1 → 只看自己发起的。默认看本 scope 全部(leader 之间要能互相看见,
    // 否则"这个诊所这周分了多少"永远拼不出来)
    return this.assignments.list(scope, mine === '1' ? user.sub : undefined, {
      ...(limit ? { limit: Number(limit) } : {}),
      ...(before ? { before } : {}),
    });
  }

  @Post(':id/revoke')
  @RequirePermission(Permission.PLAN_DISPATCH)
  @ZodResponse({ status: 200, type: RevokeAssignmentResponseDto })
  @ApiOperation({
    summary: '撤销整批(限时)—— 分错人 / 条件填错时的补救',
    description:
      '⚠️ 撤销 ≠ 退回:这是**主管收回整批**,不是客服退单条。' +
      '已被客服打开过(view 事件)的单**不收**并如实回报 —— 他可能已经联系了患者。' +
      '超出撤销窗口后请走逐条退回(那条路会留下原因)。',
  })
  async revoke(
    @TenantScope() scope: TenantScopeContext,
    @CurrentUser() user: AuthenticatedUser,
    @Param('id') id: string,
  ) {
    return this.assignments.revoke(scope, { userId: user.sub, permissions: user.permissions }, id);
  }

  /**
   * 给**已确认**的批次补挂 / 改 / 撤福利。
   *
   * ⚠️ 由来(2026-08-06 实测):福利原本只随 `create` 落库。主管确认之后再说
   *   「这批带上八折」,助手把 `set_benefit` 推给卡片,卡片改了自己的 state、亮了角标、
   *   回了句「已更新」—— DB 一个字没变,话术里也没有福利。界面说做了、实际没做。
   *   补这条路是为了让"确认后再想起福利"这件**很常见**的事真的能做成,
   *   而不是逼主管撤销整批重分(撤销还有 30 分钟窗口和"已打开的不收"两道限制)。
   */
  /**
   * 🔴 **重新排一版(尽量排满)** —— 卡片上那个按钮。
   *
   * ⚠️ 这条路**不经过助手**:主管点的是卡片上的按钮,不是说一句话。
   *   走 HTTP 直接换掉那张卡,不用等模型再跑一轮(它也没有别的事可做)。
   * ⚠️ 纯只读,和 propose 一样 —— **一个字都没写库**。
   */
  /**
   * 🔴 **点格子之后那张中间表** —— 这一格圈定的全部患者,分页。
   *
   * ⚠️ 与 `propose` 走**同一段** `cohortWhereSql` + 同一个排序键(见 service 注释):
   *   主管在这张表第 1 页看到的人,就是助手会先挑走的那批。⛔ 别为它另写查询。
   * ⚠️ 纯只读,一个字没写库(与 propose 同性质)。
   */
  @Get('cohort')
  @RequirePermission(Permission.PLAN_DISPATCH)
  @ApiOperation({
    summary: '这一格圈定的全部患者(分页)—— 交给助手之前先给主管过目',
    description:
      '与出确认单**同源**:同一个人群过滤 + 同一个排序键。' +
      'offset 分页(候选集是静态快照、且主管要可回跳的页码);单页上限 200。',
  })
  async cohort(
    @TenantScope() scope: TenantScopeContext,
    @Query('clinicId') clinicId: string,
    @Query('potentialTreatment') potentialTreatment?: string,
    @Query('temperature') temperature?: string,
    @Query('anchor') anchor?: string,
    @Query('personaTags') personaTags?: string,
    /// ↓ 一个搜索框打通五个字段(姓名/病历号/主治/上次接诊/专属客服):
    ///   只收窄这张表,⛔ 不改变交给助手的人群(见 service 注释)
    @Query('keyword') keyword?: string,
    @Query('page') page?: string,
    @Query('pageSize') pageSize?: string,
  ) {
    return this.proposals.cohortPage(scope, {
      // 🔴 越权闸:这条路会吐**患者姓名**。service 里还会再过一次(幂等)——
      //   两层都留着是刻意的:controller 这层让"收了 clinicId 就必须过闸"这条纪律
      //   在**源码上可数**(tests/mcp-clinic-scope.spec.ts 就是数这个),
      //   service 那层保证换任何入口进来都拦得住。
      clinicId: resolveClinicId(scope, clinicId),
      ...(potentialTreatment ? { potentialTreatment } : {}),
      ...(temperature ? { temperature: temperature as never } : {}),
      anchorMode: parseAnchorMode(anchor),
      ...(personaTags ? { personaTags } : {}),
      ...(keyword ? { keyword } : {}),
      ...(page ? { page: Number(page) } : {}),
      ...(pageSize ? { pageSize: Number(pageSize) } : {}),
    });
  }

  @Post('propose/refill')
  @RequirePermission(Permission.PLAN_DISPATCH)
  @ApiOperation({
    summary: '重新排一版:遇到专属排满的就跳过,从池子里往后取,尽量凑满 N',
    description:
      '⚠️ **不改变"不动别人客户"这条底线** —— 只是换一批**专属没排满 / 无主**的人来凑,' +
      '一条专属关系都不动。代价是这批人整体排名往后走(产品判定:批内名次对主管没有意义,' +
      '这批人本来就共享同一组特征)。',
  })
  async refill(
    @TenantScope() scope: TenantScopeContext,
    @Body() body: RefillProposalRequestDto,
  ) {
    return this.proposals.propose(scope, {
      clinicId: body.clinicId,
      ...(body.potentialTreatment ? { potentialTreatment: body.potentialTreatment } : {}),
      ...(body.temperature ? { temperature: body.temperature as never } : {}),
      // ⚠️ 口径原样带回 —— 少了它,「重新排一版」会静默换成另一批人
      anchorMode: parseAnchorMode(body.anchorMode),
      ...(body.personaTags ? { personaTags: body.personaTags } : {}),
      ...(body.targetCount ? { targetCount: body.targetCount } : {}),
      ...(body.expiresInDays ? { expiresInDays: body.expiresInDays } : {}),
      preferPlaceable: true,
    });
  }

  @Post(':id/benefit')
  @RequirePermission(Permission.PLAN_DISPATCH)
  @ZodResponse({ status: 200, type: SetAssignmentBenefitResponseDto })
  @ApiOperation({
    summary: '改本批福利(确认后仍可改)—— 空串 = 撤掉',
    description:
      '福利是**前向**的:只影响此后生成的话术,所以**不设时间窗**(与撤销不同)。' +
      '⚠️ 改动会作废本批的话术缓存(懒重生成);已被客服打开过的条数会如实回报 —— ' +
      '那些人手里拿的是旧话术,补挂的福利他看不到。',
  })
  async setBenefit(
    @TenantScope() scope: TenantScopeContext,
    @CurrentUser() user: AuthenticatedUser,
    @Param('id') id: string,
    @Body() body: SetAssignmentBenefitRequestDto,
  ) {
    return this.assignments.setBenefit(
      scope,
      { userId: user.sub, permissions: user.permissions },
      id,
      body.text,
    );
  }

  @Get(':id')
  @RequirePermission(Permission.PLAN_DISPATCH)
  @ZodResponse({ status: 200, type: AssignmentDetailResponseDto })
  @ApiOperation({ summary: '单批全貌 —— 按客服拆 + 退回原因分布' })
  async detail(@TenantScope() scope: TenantScopeContext, @Param('id') id: string) {
    return this.assignments.detail(scope, id);
  }
}
