import { Injectable, Logger } from '@nestjs/common';
import { Cron } from '@nestjs/schedule';
import { ConfigService } from '@nestjs/config';
import { PrismaService } from '../prisma/prisma.service';
import { PlanLabelService } from '../modules/plan/plan-label.service';
import type { AppConfig } from '../config/configuration';

/**
 * InvocationRetentionService —— 定期把 `agent_invocations` 里**过期的肥字段**清掉，只留元数据。
 *
 * ═══ 为什么必须有 ═══════════════════════════════════════════════
 * schema 上早就写着「成功调用 N 天后清 inputSnapshot 仅保留元数据」，但这个任务一直不存在。
 * 助手接进来之后写入频率上一个量级（每轮对话一行），⚠️ 而测试机盘常年 90%+、
 * PG 卷已占 75 G —— **不清就是把一个已知的紧张问题变成事故**。
 *
 * ═══ 清什么、留什么 ════════════════════════════════════════════
 * 清：inputSnapshot（置为 {"pruned":true}）· promptTemplate · systemPrompt · outputText
 *     —— 这四样占了一行 95% 以上的字节。
 * 留：token / 成本 / 延迟 / status / callKey / promptVersion / modelName / judge / userFeedback
 *     —— 仪表盘、成本聚合、通过率统计要的全在这些列上，⛔ 一个都不动。
 *
 * ⚠️ **失败的留更久**（默认 3 倍）：失败行的输入正是排查时唯一能看的东西。
 * ⚠️ `inputSnapshot` 是 NOT NULL 列 ⇒ 只能覆盖成占位对象，⛔ 不能置 null。
 * ⚠️ ⛔ 不删行：元数据行 ~1 KB，留着才有长期的成本 / 通过率曲线；肥字段清掉后
 *   增长量已经可控（按 1800 次/天算约 0.65 GB/年）。
 */
@Injectable()
export class InvocationRetentionService {
  private readonly logger = new Logger(InvocationRetentionService.name);

  constructor(
    private readonly prisma: PrismaService,
    private readonly config: ConfigService<AppConfig, true>,
  ) {}

  /** 每天 04:00（沪）—— 避开 DW 08:00 落库与随后的增量摄入。 */
  @Cron(process.env.PAC_INVOCATION_RETENTION_CRON || '0 4 * * *', {
    name: 'invocation-retention',
    timeZone: 'Asia/Shanghai',
  })
  async prune(): Promise<void> {
    const days = this.config.get('ai', { infer: true }).invocationRetentionDays;
    if (days <= 0) {
      this.logger.log('保留期配置为 0 —— 跳过清理（AI_INVOCATION_RETENTION_DAYS）');
      return;
    }
    const okCount = await this.pruneOlderThan(days, ['succeeded', 'cached']);
    // 失败的留 3 倍时长:排查时唯一能看的就是它的输入
    const failCount = await this.pruneOlderThan(days * 3, ['failed']);
    this.logger.log(
      `invocation 清理完成：成功/缓存 ${okCount} 行（>${days} 天）· 失败 ${failCount} 行（>${days * 3} 天）`,
    );
  }

  /**
   * 清一批。⚠️ `updatedAt` 是 `@updatedAt` 列，这次 update 会把它刷新 ——
   * 所以判据用 `startedAt`，⛔ 不能用 updatedAt（否则清过的行永远追不上闸门，每天重清一遍）。
   */
  private async pruneOlderThan(days: number, statuses: string[]): Promise<number> {
    const cutoff = new Date(Date.now() - days * 24 * 60 * 60 * 1000);
    const res = await this.prisma.agentInvocation.updateMany({
      where: {
        startedAt: { lt: cutoff },
        status: { in: statuses },
        // ⭐ 幂等闸：已经清过的不再进来（否则每天全表扫一遍已清的行）
        NOT: { inputSnapshot: { equals: { pruned: true } } },
      },
      data: {
        inputSnapshot: { pruned: true },
        promptTemplate: null,
        systemPrompt: null,
        outputText: null,
      },
    });
    return res.count;
  }
}

/**
 * PlanLabelRefreshService —— 每晚重算 `plan_reasons.potential_labels`。
 *
 * 🔴 **不刷就会逐日漂，且不会有任何报错**：标签规则里有三条带年龄
 *   （K08>18 / K07 3~12 / K07 13~40），患者过生日跨档时标签就该变。
 *   实测本地受影响 5,551 人中「明天跨档 0 人、30 天内 16 人」（≈0.5 人/天）——
 *   每晚刷一次误差可忽略，但**一天不刷就多欠一天**。
 *
 * ⚠️ 排在画像重算之后（03:30）：标签只依赖 fact 与生日，与画像无先后依赖，
 *   但错开时段避免两个全表任务抢 I/O。
 */
@Injectable()
export class PlanLabelRefreshService {
  private readonly logger = new Logger(PlanLabelRefreshService.name);

  constructor(private readonly labels: PlanLabelService) {}

  @Cron(process.env.PAC_PLAN_LABEL_REFRESH_CRON || '30 3 * * *', {
    name: 'plan-label-refresh',
    timeZone: 'Asia/Shanghai',
  })
  async refresh(): Promise<void> {
    const missing = await this.labels.countMissing();
    if (missing > 0) {
      // 平时该是 0（生成完就补）。非 0 说明有条路写了 reason 却没补标签 —— 值得看一眼。
      this.logger.warn(`[标签] 刷新前发现 ${missing} 条没算过 —— 生成路径可能漏了补齐`);
    }
    const n = await this.labels.refreshAll();
    this.logger.log(`[标签] 夜间重算完成，${n} 条因年龄跨档等原因发生变化`);
  }
}
