import { Injectable, Logger, NotFoundException } from '@nestjs/common';
import type { Prisma } from '@prisma/client';
import { PrismaService } from '../../prisma/prisma.service';
import { FeatureRegistry } from './features/feature.registry';
import type {
  ActiveFact,
  FeatureExtractorContext,
  PersonaFeatureDraft,
} from './features/feature.interface';

/**
 * PersonaService — 第二层画像引擎
 *
 * 三个能力:
 *   1. recompute(patientId, source) — 跑所有 extractors,写新 Persona 版本(supersede 旧)
 *   2. getCurrent(patientId)        — 读当前 active 版本(含 features)
 *   3. getByVersion(patientId, v)   — 读历史版本
 *
 * 触发场景:
 *   - 事件驱动:transaction 落库后 BullMQ 队列 jobName=patient_id 串行调用(W3+)
 *   - 手动:CLI / Admin API
 *   - 定时兜底:每日 cron 全扫
 *
 * 水位:Persona.eventWatermark = 计算时所见 patient_transactions.eventSeq 最大值;
 *      下次重算前比对:max(event_seq) ≤ watermark → no-op(stale 判断)
 */
@Injectable()
export class PersonaService {
  private readonly logger = new Logger(PersonaService.name);

  /// 租户级 M(累计净消费)分位阈值缓存:key=host:tenant → { at, q:[p20,p40,p60,p80] }。
  /// RFM 的 M 打分要群体分位(图 B.1.1),但 PAC 按患者重算 → 分位阈值缓存复用(分布慢变);
  /// 批量重算每轮首个患者算一次,后续命中缓存;TTL 兜底单刷场景。
  private readonly mQuantileCache = new Map<string, { at: number; q: number[] }>();
  private static readonly M_QUANTILE_TTL_MS = 30 * 60 * 1000;

  constructor(
    private readonly prisma: PrismaService,
    private readonly registry: FeatureRegistry,
  ) {}

  /** 租户内"累计净消费/患者"的 [p20,p40,p60,p80](cents)。缓存 30min;失败/无数据 → []。 */
  private async getMonetaryQuantiles(hostId: string, tenantId: string, nowMs: number): Promise<number[]> {
    const key = `${hostId}:${tenantId}`;
    const hit = this.mQuantileCache.get(key);
    if (hit && nowMs - hit.at < PersonaService.M_QUANTILE_TTL_MS) return hit.q;
    try {
      const rows = await this.prisma.$queryRaw<Array<{ q: number[] }>>`
        WITH spend AS (
          SELECT patient_id, SUM(
            CASE type WHEN 'payment_record'  THEN (content->>'amount_cents')::bigint
                      WHEN 'recharge_record' THEN (content->>'amount_cents')::bigint
                      WHEN 'refund_record'   THEN -(content->>'amount_cents')::bigint END) AS net
          FROM patient_facts
          WHERE host_id = ${hostId}::uuid AND tenant_id = ${tenantId}
            AND status IN ('active','fulfilled')
            AND type IN ('payment_record','recharge_record','refund_record')
          GROUP BY patient_id)
        SELECT percentile_cont(ARRAY[0.2,0.4,0.6,0.8]) WITHIN GROUP (ORDER BY net)::float8[] AS q FROM spend`;
      const q = (rows[0]?.q ?? []).map((x) => Number(x));
      this.mQuantileCache.set(key, { at: nowMs, q });
      return q;
    } catch (err) {
      this.logger.warn(`getMonetaryQuantiles failed (${key}): ${err instanceof Error ? err.message : err}`);
      return [];
    }
  }

  /**
   * 重算单个 patient 的 Persona,产新版本并 supersede 旧 active。
   *
   * @returns 新写的 Persona id;无 fact 数据时仍写一版(各 extractor 自行降级)
   */
  async recompute(input: {
    patientId: string;
    source: string; // 'txn:<id>' / 'manual:<userId>' / 'scheduled:<cronId>'
    now?: Date;
    force?: boolean; // 跳过水位幂等闸:算法/特征变更后重算(数据没变也要重跑)
  }): Promise<RecomputeResult> {
    const startedAt = new Date();
    const now = input.now ?? new Date();

    const patient = await this.prisma.patient.findUnique({
      where: { id: input.patientId },
      include: { profile: true },
    });
    if (!patient) {
      throw new NotFoundException(`Patient ${input.patientId} not found`);
    }

    // ─── 水位幂等(spec: 第二层 · 关键纪律)───
    // 如果当前 active Persona 的 eventWatermark ≥ 最新 transaction 的 eventSeq,
    // 表示这个 patient 自上次 recompute 后没有新事件 → 直接 noop,省一次全量提取。
    // 注:txn 没有 → 不算 noop(允许"无事实"的 patient 也跑一次写 baseline persona);
    //     persona 没有 → 不算 noop(首次必跑)。
    const latestTxnForGate = await this.prisma.patientTransaction.findFirst({
      where: { hostId: patient.hostId, tenantId: patient.tenantId, patientId: patient.id },
      orderBy: { eventSeq: 'desc' },
      select: { eventSeq: true },
    });
    const currentPersona = await this.prisma.persona.findFirst({
      where: { patientId: patient.id, supersededAt: null },
      select: { id: true, version: true, eventWatermark: true },
      orderBy: { version: 'desc' },
    });

    if (
      !input.force &&
      currentPersona &&
      latestTxnForGate &&
      currentPersona.eventWatermark !== null &&
      latestTxnForGate.eventSeq <= currentPersona.eventWatermark
    ) {
      // 落一条 noop 日志便于审计 — 区别于实际算了一遍
      await this.prisma.personaRecomputeLog.create({
        data: {
          hostId: patient.hostId,
          tenantId: patient.tenantId,
          patientId: patient.id,
          triggeredBy: input.source,
          status: 'noop',
          producedPersonaId: currentPersona.id,
          featureCount: 0,
          ruleCount: 0,
          agentCount: 0,
          fallbackCount: 0,
          eventWatermark: latestTxnForGate.eventSeq,
          endedAt: new Date(),
        },
      });
      return {
        personaId: currentPersona.id,
        version: currentPersona.version,
        featureCount: 0,
        ruleCount: 0,
        failed: 0,
        status: 'noop',
        durationMs: Date.now() - startedAt.getTime(),
      };
    }

    const log = await this.prisma.personaRecomputeLog.create({
      data: {
        hostId: patient.hostId,
        tenantId: patient.tenantId,
        patientId: patient.id,
        triggeredBy: input.source,
        status: 'running',
      },
    });

    try {
      const facts = await this.prisma.patientFact.findMany({
        where: {
          hostId: patient.hostId,
          tenantId: patient.tenantId,
          patientId: patient.id,
          // 跟 chain-composer / scenario SQL 对齐:actual treatment 完成后 status='fulfilled',
          // 不能只取 'active' 否则已完成治疗在 persona 看不到 → treatment_chain_status 误算 gap
          status: { in: ['active', 'fulfilled'] },
        },
        select: {
          id: true,
          subjectId: true,
          type: true,
          kind: true,
          status: true,
          occurredAt: true,
          plannedFor: true,
          clinicId: true,
          content: true,
        },
      });

      const factsByType = new Map<string, ActiveFact[]>();
      for (const f of facts) {
        const arr = factsByType.get(f.type) ?? [];
        arr.push(f as ActiveFact);
        factsByType.set(f.type, arr);
      }

      const latestTxn = await this.prisma.patientTransaction.findFirst({
        where: { hostId: patient.hostId, tenantId: patient.tenantId, patientId: patient.id },
        orderBy: { eventSeq: 'desc' },
        select: { eventSeq: true },
      });
      const eventWatermark = latestTxn?.eventSeq ?? null;

      const ctx: FeatureExtractorContext = {
        patient: {
          id: patient.id,
          externalId: patient.externalId,
          name: patient.name,
          phone: patient.phone,
          gender: patient.gender,
          birthDate: patient.birthDate,
          active: patient.active,
        },
        profile: patient.profile
          ? {
              doNotContact: patient.profile.doNotContact,
              doNotContactReason: patient.profile.doNotContactReason,
              deceased: patient.profile.deceased,
              tags: patient.profile.tags,
              notes: patient.profile.notes,
            }
          : null,
        factsByType,
        now,
        populationStats: {
          monetaryQuantiles: await this.getMonetaryQuantiles(
            patient.hostId,
            patient.tenantId,
            now.getTime(),
          ),
        },
      };

      const drafts: PersonaFeatureDraft[] = [];
      let ruleCount = 0;
      let failed = 0;
      for (const ex of this.registry.extractors) {
        try {
          const d = ex.extract(ctx);
          if (d) {
            drafts.push(d);
            ruleCount++;
          }
        } catch (err) {
          failed++;
          this.logger.error(
            `feature ${ex.key} extract failed for patient=${patient.id}: ${err instanceof Error ? err.message : err}`,
          );
        }
      }

      const latest = await this.prisma.persona.findFirst({
        where: { patientId: patient.id },
        orderBy: { version: 'desc' },
      });
      const nextVersion = (latest?.version ?? 0) + 1;

      const created = await this.prisma.$transaction(async (tx) => {
        if (latest && !latest.supersededAt) {
          await tx.persona.update({
            where: { id: latest.id },
            data: { supersededAt: new Date() },
          });
        }
        return tx.persona.create({
          data: {
            hostId: patient.hostId,
            tenantId: patient.tenantId,
            patientId: patient.id,
            version: nextVersion,
            source: input.source as unknown as string,
            eventWatermark,
            features: {
              createMany: {
                data: drafts.map((d) => ({
                  key: d.key,
                  description: d.description,
                  score: d.score ?? null,
                  data:
                    d.data == null
                      ? undefined // nullable Json 列省略 → NULL
                      : (d.data as unknown as Prisma.InputJsonValue),
                  evidence: d.evidence as unknown as Prisma.InputJsonValue,
                })),
              },
            },
          },
        });
      }, { maxWait: 30000, timeout: 60000 });

      const status = failed === 0 ? 'success' : drafts.length > 0 ? 'partial' : 'failed';
      await this.prisma.personaRecomputeLog.update({
        where: { id: log.id },
        data: {
          status,
          producedPersonaId: created.id,
          featureCount: drafts.length,
          ruleCount,
          agentCount: 0,
          fallbackCount: 0,
          eventWatermark,
          endedAt: new Date(),
        },
      });

      return {
        personaId: created.id,
        version: nextVersion,
        featureCount: drafts.length,
        ruleCount,
        failed,
        status,
        durationMs: Date.now() - startedAt.getTime(),
      };
    } catch (err) {
      await this.prisma.personaRecomputeLog.update({
        where: { id: log.id },
        data: {
          status: 'failed',
          errorMessage: err instanceof Error ? err.message : String(err),
          endedAt: new Date(),
        },
      });
      throw err;
    }
  }

  async getCurrent(patientId: string) {
    const p = await this.prisma.persona.findFirst({
      where: { patientId, supersededAt: null },
      include: { features: true },
      orderBy: { version: 'desc' },
    });
    return p ? this.serialize(p) : null;
  }

  async getByVersion(patientId: string, version: number) {
    const p = await this.prisma.persona.findUnique({
      where: { patientId_version: { patientId, version } },
      include: { features: true },
    });
    return p ? this.serialize(p) : null;
  }

  private serialize(p: {
    id: string;
    hostId: string;
    tenantId: string;
    patientId: string;
    version: number;
    source: string;
    computedAt: Date;
    supersededAt: Date | null;
    eventWatermark: bigint | null;
    features: Array<{
      id: string;
      personaId: string;
      key: string;
      description: string;
      score: number | null;
      data: unknown;
      evidence: unknown;
      createdAt: Date;
    }>;
  }) {
    return {
      id: p.id,
      hostId: p.hostId,
      tenantId: p.tenantId,
      patientId: p.patientId,
      version: p.version,
      triggeredBy: p.source,
      computedAt: p.computedAt.toISOString(),
      supersededAt: p.supersededAt?.toISOString() ?? null,
      eventWatermark: p.eventWatermark != null ? p.eventWatermark.toString() : null,
      features: p.features.map((f) => ({
        id: f.id,
        personaId: f.personaId,
        key: f.key,
        description: f.description,
        score: f.score,
        data: (f.data as Record<string, unknown>) ?? null,
        evidence: (f.evidence as Record<string, unknown>) ?? {},
        createdAt: f.createdAt.toISOString(),
      })),
    };
  }
}

export interface RecomputeResult {
  personaId: string;
  version: number;
  featureCount: number;
  ruleCount: number;
  failed: number;
  status: string;
  durationMs: number;
}
