import { Injectable, Logger, NotFoundException, BadRequestException } from '@nestjs/common';
import { EXECUTION_OUTCOME_META, ExecutionOutcome } from '@pac/types';
import { PrismaService } from '../../prisma/prisma.service';
import { QueueProducer } from '../../queues/queue-producer.service';
import { resolveSnoozedUntil } from './recall-suppression';
import { assertPlanClaimedBy } from './claim-guard';
import type { TenantScopeContext } from '../../common/decorators/tenant-scope.decorator';

/**
 * ExecutionService — PlanExecution 回写 + Plan 状态机 + 熔断检查
 *
 * 触发路径(spec three-layer-model § "PlanExecution 回写"):
 *   客服工作台 → POST /pac/v1/.../plans/{id}/executions
 *      ↓
 *   1) 校验 plan 存在 + 仍可写(active/assigned)
 *   2) plan_executions 写一行(append-only,不更新)
 *   3) followup_plans.contactAttempts++
 *   4) 按 outcome 决定 plan.status 新值(见 OUTCOME_TO_STATUS)
 *   5) 熔断:若 contactAttempts >= maxContactAttempts 且未 completed → 强制 abandoned
 *   6) 返回 executionId + 新 plan.status(让前端无需重拉)
 *
 * 单向数据流:本服务**不回写 patient_facts / patient_transactions**(spec v1 纪律)。
 * 召回成功新预约靠下次 pull 时 patient_transactions 自动反映,不在本表立柱关联。
 */

const MAX_CONTACT_ATTEMPTS_DEFAULT = 4; // 暂常量,后续接 host/scenario 配置

/// 约定回访后,给经办人在回访日之后再留多久才允许自动回收(宽限期)。
/// 让"约定回访"的 plan 在回访日当天仍归经办人,不会被 24h 超时提前回收 / 回访日一到就被别人捞走。
const RECYCLE_GRACE_AFTER_SNOOZE_MS = 24 * 3600_000;

/**
 * outcome → plan.status 映射(spec § "按 outcome 驱动 Plan.status"):
 *   completed   = 闭环成功 / 不再需要召回(转化 / 外院 / 明确拒绝 / 无效)
 *   abandoned   = 客服主动放弃
 *   keep        = 维持当前 status(等下次或下次回访)
 */
type StatusTransition = 'completed' | 'abandoned' | 'keep';

/**
 * outcome → 状态机映射 — **派生自 @pac/types 单一真理源**
 *
 * 老版本(W4 早期)在这里 hardcode 一份 OUTCOME_TO_STATUS,前端 mock-data
 * 也单独维护一份 `drives` 字段,3 处口径漂移已踩过坑。
 * 现在统一从 EXECUTION_OUTCOME_META.drivesStatus 派生,改一处全生效。
 */
const OUTCOME_TO_STATUS: Record<string, StatusTransition> = Object.fromEntries(
  Object.entries(EXECUTION_OUTCOME_META).map(([k, v]) => [k, v.drivesStatus]),
);

export interface SubmitExecutionInput {
  channel: string;
  outcome: string;
  notes?: string;
  abandonReasons?: string[];
  /** 「机会识别不准确」勾中的治疗 code(仅统计,不参与抑制);选了 inaccurate 必填 */
  inaccurateTreatments?: string[];
  scheduledNextAt?: string;
  /** 显式覆盖执行诊所;不传则用 plan.targetClinicId 或 scope 第一个 clinicId */
  executorClinicId?: string;
}

export interface SubmitExecutionResult {
  executionId: string;
  planStatus: string;
  contactAttempts: number;
  breakerTripped: boolean;
}

@Injectable()
export class ExecutionService {
  private readonly logger = new Logger(ExecutionService.name);

  constructor(
    private readonly prisma: PrismaService,
    private readonly queue: QueueProducer,
  ) {}

  async submit(
    scope: TenantScopeContext,
    planId: string,
    operatorUserId: string,
    input: SubmitExecutionInput,
  ): Promise<SubmitExecutionResult> {
    // ─── 1. 校验 plan ───
    const plan = await this.prisma.followupPlan.findUnique({
      where: { id: planId },
      select: {
        id: true,
        hostId: true,
        tenantId: true,
        patientId: true,
        status: true,
        contactAttempts: true,
        targetClinicId: true,
        assigneeUserId: true,
        // ⭐ 批次归因:这次执行算在哪一批头上(见下方落库处的判据)
        assignmentId: true,
        assignmentExpiresAt: true,
      },
    });
    if (!plan) throw new NotFoundException(`Plan ${planId} not found`);
    if (plan.hostId !== scope.hostId || plan.tenantId !== scope.tenantId) {
      throw new NotFoundException(`Plan ${planId} not found`);  // 跨 tenant 故意伪装 404
    }
    if (plan.status !== 'active' && plan.status !== 'assigned') {
      throw new BadRequestException(
        `Plan ${planId} 当前状态 ${plan.status},不可写 execution`,
      );
    }
    // ⭐ 认领闸:未认领 / 被别人认领 → 拒绝(见 claim-guard.ts)。
    //    放在状态校验之后 —— "这单已经结案了"比"你没认领"更该先说。
    assertPlanClaimedBy(plan, operatorUserId);

    // ─── 2. 解析 executorClinicId ───
    const executorClinicId =
      input.executorClinicId ??
      plan.targetClinicId ??
      scope.clinicIds[0] ??
      'demo-clinic';

    // ─── 3. 解析 outcome → 新 status ───
    const transition = OUTCOME_TO_STATUS[input.outcome];
    if (!transition) {
      throw new BadRequestException(`未知 outcome=${input.outcome}`);
    }

    // ⭐ 选了「机会识别不准确」必须说清是哪几类治疗不准 —— **服务端也拦**,不能只信前端:
    //   这条数据的唯一用途是回头统计"哪类召回最容易被判不准"去改规则,收进来一批空的
    //   就等于这次反馈白填了,而且事后补不回来(客服不会为一条已结案的单再来一遍)。
    if (input.abandonReasons?.includes('inaccurate') && !input.inaccurateTreatments?.length) {
      throw new BadRequestException('选了「机会识别不准确」时,必须勾选具体哪些推荐治疗不准');
    }

    const nextContactAttempts = plan.contactAttempts + 1;
    let newStatus: string = plan.status;
    let breakerTripped = false;

    if (transition === 'completed') {
      newStatus = 'completed';
    } else if (transition === 'abandoned') {
      newStatus = 'abandoned';
    } else {
      // keep:status 不变,但要检查熔断
      if (nextContactAttempts >= MAX_CONTACT_ATTEMPTS_DEFAULT) {
        newStatus = 'abandoned';
        breakerTripped = true;
        this.logger.warn(
          `plan=${planId} 熔断:contactAttempts=${nextContactAttempts} >= ${MAX_CONTACT_ATTEMPTS_DEFAULT},强制 abandoned`,
        );
      }
    }

    // ─── 3.5 计算 snoozedUntil(召回闭环核心,见 resolveSnoozedUntil)───
    //   - 终态 + snoozedUntil → plan 引擎 upsert 时不重新生成(防熔断/拒绝被重算复活)
    //   - keep 类 + snoozedUntil → active plan 到期前不进召回池(到点浮现)
    const now = new Date();
    const snoozedUntil = resolveSnoozedUntil({
      outcome: input.outcome,
      breakerTripped,
      scheduledNextAt: input.scheduledNextAt,
      now,
      abandonReasons: input.abandonReasons,
    });

    // ─── 4. 事务:写 execution + 更新 plan ───
    const result = await this.prisma.$transaction(async (tx) => {
      const execution = await tx.planExecution.create({
        data: {
          hostId: plan.hostId,
          tenantId: plan.tenantId,
          executorClinicId,
          planId: plan.id,
          operatorUserId,
          channel: input.channel,
          outcome: input.outcome,
          notes: input.notes ?? null,
          abandonReasons: input.abandonReasons ?? [],
          // 只在选了 inaccurate 时落 —— 其他原因不构成关联(业务 2026-07-30),
          // 免得前端残留的勾选被顺带写进去,污染统计口径
          inaccurateTreatments: input.abandonReasons?.includes('inaccurate')
            ? (input.inaccurateTreatments ?? [])
            : [],
          scheduledNextAt: input.scheduledNextAt ? new Date(input.scheduledNextAt) : null,
          /**
           * ⭐ 批次归因 —— 判据是**在办期**(`assignment_expires_at != null`),
           *   那一列的自洽性就是「非空 ⟺ 有一次在办的分配」(退回/到期/撤销都会清空它)。
           * ⛔ **不要**改成 `plan.assignmentId` 直接落:它在退回之后**仍然留着**
           *   (那是退回率的分母,刻意不清)。此时客服自己从池子里捞回来打的这通电话,
           *   会被算进一个早就结束的批次 —— 那批的"成功数"凭空多一个,且不报错。
           */
          assignmentId: plan.assignmentExpiresAt != null ? plan.assignmentId : null,
        },
        select: { id: true },
      });

      await tx.followupPlan.update({
        where: { id: plan.id },
        data: {
          status: newStatus,
          contactAttempts: nextContactAttempts,
          // 召回冷静期 / 终态抑制窗(null 时显式清空 → 重新触达后老 snooze 不残留)
          snoozedUntil,
          // 终态:只清自动回收 deadline(不再相关)。
          // ⭐ 保留 assigneeUserId / assignedAt 作业绩归属 —— 让"我的已完成" tab 看得到、
          //   "我完成"KPI + 转化率算得对。离开"进行中"靠 status 变化(列表按 status 过滤),
          //   不靠清 assignee(清了会让 mineCompleted / 转化率 永远为 0)。
          // keep 类 + 有 snooze(约定回访 6/10 等):把 recycleAt 顺延到回访日 + 宽限,
          //   否则分配 24h 后 recycleAt 一过,回收 cron 会把约了回访的 plan 从经办人手里收走。
          ...(newStatus === 'completed' || newStatus === 'abandoned'
            ? { recycleAt: null }
            : snoozedUntil
              ? { recycleAt: new Date(snoozedUntil.getTime() + RECYCLE_GRACE_AFTER_SNOOZE_MS) }
              : {}),
        },
      });

      return execution;
    }, { maxWait: 30000, timeout: 60000 });

    // ─── 5. 执行结果回执(opt-in:宿主配了 callbackUrl 才投递)──────
    // 事务已提交,回执异步走队列(HMAC 签名 + 退避重试),不阻塞客服提交返回。
    const host = await this.prisma.host.findUnique({
      where: { id: plan.hostId },
      select: { callbackUrl: true },
    });
    if (host?.callbackUrl) {
      await this.queue
        .enqueueExecutionCallback({
          hostId: plan.hostId,
          tenantId: plan.tenantId,
          executionId: result.id,
          triggeredBy: `execution:${operatorUserId}`,
        })
        .catch((err) =>
          // 入队失败不影响客服提交(execution 已落库);只告警,靠日志兜
          this.logger.error(`回执入队失败 execution=${result.id}: ${(err as Error).message}`),
        );
    }

    return {
      executionId: result.id,
      planStatus: newStatus,
      contactAttempts: nextContactAttempts,
      breakerTripped,
    };
  }

  /**
   * 列出某 plan 的执行历史(给"通话记录"面板用)
   */
  async list(scope: TenantScopeContext, planId: string) {
    const plan = await this.prisma.followupPlan.findUnique({
      where: { id: planId },
      select: { id: true, hostId: true, tenantId: true },
    });
    if (!plan || plan.hostId !== scope.hostId || plan.tenantId !== scope.tenantId) {
      throw new NotFoundException(`Plan ${planId} not found`);
    }
    const executions = await this.prisma.planExecution.findMany({
      where: { planId },
      orderBy: { createdAt: 'desc' },
    });
    return executions.map((e) => ({
      id: e.id,
      hostId: e.hostId,
      tenantId: e.tenantId,
      planId: e.planId,
      executorClinicId: e.executorClinicId,
      operatorUserId: e.operatorUserId,
      channel: e.channel,
      outcome: e.outcome as ExecutionOutcome,
      notes: e.notes,
      abandonReasons: e.abandonReasons,
      inaccurateTreatments: e.inaccurateTreatments,
      scheduledNextAt: e.scheduledNextAt?.toISOString() ?? null,
      createdAt: e.createdAt.toISOString(),
    }));
  }
}
