import { ApiCode } from '@pac/types';
import { BizError } from '../../common/errors/biz-error';

/**
 * 认领闸 —— 「未认领只能看,不能操作」的服务端硬约束。
 *
 * 为什么必须在服务端拦(不能只做前端禁用):
 *   1. **执行必须有归属**。execution.service 特意保留 assigneeUserId 作业绩归属
 *      (见那里的注释:清了会让「我的已完成」/ 转化率永远为 0)。未认领就提交执行 =
 *      这条执行没有任何归属人,统计里直接蒸发 —— 前端提示拦不住这个数据问题。
 *   2. **防撞单**。两个客服同时打同一个患者,只有服务端能仲裁。
 *   3. 生产实证(2026-07):缺牙召回有两条 plan 在 `assignee IS NULL` 状态下被直接关闭,
 *      当时 submitExecution 只校验了 status、完全没看 assignee。
 *
 * ⚠️ 适用边界 —— 不是所有写操作都该过这道闸:
 *   过闸:提交通话结果 / 关闭机会、重生成话术(含 SSE)、话术反馈、召回反馈
 *   不过闸:
 *     · `assign`(认领本身就是入口,过闸会变成死锁)
 *     · `recycle`(有自己的归属规则,见 assertCanRecycle —— 组长能回收任意人的单,
 *        按"必须自己认领"拦会把组长回收功能整个废掉)
 *     · `recompute`(ops/dev 工具,走 PLAN_ASSIGN 权限)
 *
 * 两种拒绝态分开报,文案不同:没人认领 → 引导去认领;别人认领了 → 说清是谁,
 * 否则组员会以为是自己没点对。
 */
export interface ClaimablePlan {
  assigneeUserId: string | null;
}

/**
 * 「这条单现在能不能分给某人」—— 单条 assign 与批量分配**共用同一个判据**。
 *
 * 为什么要提出来:教条七里那条待确认的产品决策「已被认领的单能否强制改派」
 * 恰好就落在这一个函数上。收口成一处,将来产品拍板了只改这里 ——
 * 散在单条与批量两处,必然只改一处然后两条路行为不一致(而且不会有人发现)。
 *
 * 返回 null = 可以分;返回字符串 = 不能分的原因码(与 AssignmentSkipped.reason 同域)。
 * ⚠️ 刻意**返回原因而不抛异常**:批量场景要的是「其余照落 + 逐条说明」,
 * 抛异常会让一条挡住整批。单条路径由调用方自己把原因翻成 BizError。
 */
export function assertAssignable(
  plan: { status: string; assigneeUserId: string | null },
  targetAssigneeUserId: string,
): 'terminal' | 'claimed_by_other' | null {
  if (plan.status === 'completed' || plan.status === 'abandoned' || plan.status === 'superseded') {
    return 'terminal';
  }
  // 已分给**别人** → 挡住(分给同一个人是幂等续期,放行)。
  // 这就是「强制改派」那条待确认决策的落点:将来若允许,改成 return null 即可。
  if (plan.status === 'assigned' && plan.assigneeUserId !== targetAssigneeUserId) {
    return 'claimed_by_other';
  }
  return null;
}

/**
 * 返池归属闸 —— staff 只能退**自己认领的**单(2026-07-29 放开 staff 返池时加)。
 *
 * 为什么必须有:光把 PLAN_RECYCLE 给 staff,客服 A 就能把客服 B 手里的单退回池、
 * 再自己认领 —— 认领闸挡的是"没认领就作业",挡不住"先把别人的单退了"。
 *
 * 分流依据是 canRecycleOthers(controller 按 PLAN_VIEW_ALL 传入),不是角色字符串:
 * 权限模型的真理源是 ROLE_PERMISSIONS,service 层不该再认一次角色。
 *
 * 无人认领(assignee=null)一律放行:那种"返池"本来就是空操作(recycle 里 hadAssignee=false
 * 不记账),拦它只会让前端多一种没意义的报错。
 */
export function assertCanRecycle(
  plan: ClaimablePlan,
  actorUserId: string | undefined,
  canRecycleOthers: boolean,
): void {
  if (canRecycleOthers) return;
  if (!plan.assigneeUserId) return;
  if (plan.assigneeUserId !== actorUserId) {
    throw new BizError(ApiCode.PLAN_CLAIMED_BY_OTHER, undefined, {
      assigneeUserId: plan.assigneeUserId,
    });
  }
}

export function assertPlanClaimedBy(plan: ClaimablePlan, userId: string): void {
  if (!plan.assigneeUserId) {
    throw new BizError(ApiCode.PLAN_NOT_CLAIMED);
  }
  if (plan.assigneeUserId !== userId) {
    // details 带上占用人,前端可查字典换成姓名展示("已被 张三 认领")
    throw new BizError(ApiCode.PLAN_CLAIMED_BY_OTHER, undefined, {
      assigneeUserId: plan.assigneeUserId,
    });
  }
}
