/**
 * Recompute Persona CLI
 *
 * 批量重算指定 host(默认 demo)所有 patient 的 Persona,产新版本。
 *
 * Usage:
 *   pnpm recompute-persona                   # 默认 host=demo,所有 active patient
 *   pnpm recompute-persona -- --host=friday  # 指定 host
 *   pnpm recompute-persona -- --pid=P001     # 单 patient(host 内 externalId)
 *   pnpm recompute-persona -- --host=jvs-dw --clinics=<orgId> --concurrency=16  # 按诊所补摄后定向重算
 */
import { NestFactory } from '@nestjs/core';
import { Logger } from '@nestjs/common';
import { AppModule } from '../app.module';
import { PersonaService } from '../modules/persona/persona.service';
import { PrismaService } from '../prisma/prisma.service';
import { RedisService } from '../redis/redis.service';
import { doctorOptionsCacheKey } from '../modules/plan/plan.service';
import { runPool } from '../common/run-pool';

interface Args {
  host: string;
  pid?: string;
  pids?: string[]; // 多个 externalId(逗号分隔),定向重算受影响子集用
  force?: boolean; // 跳过水位幂等闸:算法/特征变更后(数据没变)也强制重算
  concurrency: number; // 并发度:每患者 recompute 含 ~15 次 DB 往返,并发可重叠 I/O 等待
  shard?: { i: number; k: number }; // 分片 i/k:多进程并行(吃多核),按患者序号取模
  clinics?: string[]; // --clinics=X,Y:只重算"在这些诊所有过操作"的患者(按诊所补摄后定向重算,免全 host 白扫)
}

function parseArgs(argv: string[]): Args {
  const args: Args = { host: 'demo', concurrency: 1 };
  for (const a of argv) {
    if (a.startsWith('--host=')) args.host = a.slice('--host='.length);
    else if (a.startsWith('--pid=')) args.pid = a.slice('--pid='.length);
    else if (a === '--force') args.force = true;
    else if (a.startsWith('--pids=')) {
      args.pids = a.slice('--pids='.length).split(',').map((s) => s.trim()).filter(Boolean);
    } else if (a.startsWith('--concurrency=')) {
      const n = parseInt(a.slice('--concurrency='.length), 10);
      if (Number.isFinite(n) && n >= 1) args.concurrency = n;
    } else if (a.startsWith('--shard=')) {
      const m = a.slice('--shard='.length).match(/^(\d+)\/(\d+)$/);
      if (m) args.shard = { i: Number(m[1]), k: Number(m[2]) };
    } else if (a.startsWith('--clinics=')) {
      args.clinics = a.slice('--clinics='.length).split(',').map((s) => s.trim()).filter(Boolean);
    }
  }
  return args;
}

async function bootstrap() {
  const args = parseArgs(process.argv.slice(2));
  const logger = new Logger('recompute-persona');
  // ⭐ 必须在 NestFactory 之前设:PrismaService 在容器初始化时就按这个值算 connection_limit,晚了没用。
  //   不设的话池是 Prisma 默认的「核数×2+1」—— 生产 4 核机 = 9,--concurrency 超过 8 就会被卡住,
  //   多出来的 worker 全在排队等连接。详见 prisma.service.ts 里 withCohortDerivedPool 的注释
  //   (含两台机器的实际 .env 差异;测试服 URL 里显式写了 connection_limit=30,那里不受影响)。
  //   只在用户显式给了 --concurrency 时设,不覆盖外部已有的环境变量。
  if (args.concurrency > 1 && !process.env.PAC_DB_CONCURRENCY) {
    process.env.PAC_DB_CONCURRENCY = String(args.concurrency);
  }
  const app = await NestFactory.createApplicationContext(AppModule, {
    logger: ['log', 'warn', 'error'],
  });

  try {
    const prisma = app.get(PrismaService);
    const svc = app.get(PersonaService);

    const host = await prisma.host.findUnique({ where: { name: args.host } });
    if (!host) throw new Error(`Host '${args.host}' not found`);

    // --clinics:把重算范围收窄到"在这些诊所有过操作(patient_transactions.clinic_id)"的患者。
    // 和 cold-import --clinics 同口径,用于按诊所补摄后只重算新灌入的那批,避免全 host 白扫。
    let clinicPatientIds: string[] | undefined;
    if (args.clinics?.length) {
      const rows = await prisma.patientTransaction.findMany({
        where: { hostId: host.id, clinicId: { in: args.clinics } },
        select: { patientId: true },
        distinct: ['patientId'],
      });
      const ids = rows.map((r) => r.patientId).filter((x): x is string => !!x);
      logger.log(`--clinics=${args.clinics.join(',')} → 命中 ${ids.length} 位患者(按诊所收窄)`);
      if (ids.length === 0) throw new Error('--clinics 未命中任何患者(诊所 id 是否正确 / 是否已摄入?)');
      clinicPatientIds = ids;
    }

    const extraWhere = args.pids?.length
      ? { externalId: { in: args.pids } }
      : args.pid
        ? { externalId: args.pid }
        : {};
    let patients: { id: string; externalId: string; name: string | null }[];
    if (clinicPatientIds) {
      // Postgres 单条 prepared statement 绑定参数上限 32767 —— 大诊所(如建国门 ~5 万患者)
      // 一次 id IN (...) 会超限报错,分块查再合并。块大小留足余量给其他 where 条件。
      const CHUNK = 20000;
      const acc: { id: string; externalId: string; name: string | null }[] = [];
      for (let i = 0; i < clinicPatientIds.length; i += CHUNK) {
        const slice = clinicPatientIds.slice(i, i + CHUNK);
        const part = await prisma.patient.findMany({
          where: { hostId: host.id, active: true, id: { in: slice }, ...extraWhere },
          select: { id: true, externalId: true, name: true },
        });
        acc.push(...part);
      }
      acc.sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)); // 稳定序供分片
      patients = acc;
    } else {
      patients = await prisma.patient.findMany({
        where: { hostId: host.id, active: true, ...extraWhere },
        select: { id: true, externalId: true, name: true },
        orderBy: { id: 'asc' }, // 稳定序,供分片按下标取模(各进程不重不漏)
      });
    }
    if (args.shard) {
      const { i, k } = args.shard;
      patients = patients.filter((_, idx) => idx % k === i);
    }

    const total = patients.length;
    logger.log(
      `Recomputing ${total} patients for host=${args.host} ` +
        `(concurrency=${args.concurrency}${args.shard ? `, shard=${args.shard.i}/${args.shard.k}` : ''}) ...`,
    );

    // 五档结果(见 PersonaService.recompute):noop=闸拦下没算 / unchanged=算了内容没变 /
    // refreshed=只有天数等易变值变(就地刷新不升版本) / ok+partial=升了版本。都不是错误。
    // 看 unchanged 占比就知道闸放宽后有多少是白算的 —— 调水位口径的依据。
    let ok = 0, partial = 0, noop = 0, unchanged = 0, refreshed = 0, failed = 0, done = 0;
    const verbose = total <= 500; // 小批逐条日志;大批只打进度,免刷屏
    const runOne = async (p: { id: string; externalId: string; name: string | null }) => {
      try {
        const r = await svc.recompute({ patientId: p.id, source: `manual:cli`, force: args.force });
        if (verbose) {
          logger.log(
            `  ${p.externalId.padEnd(6)} ${(p.name ?? '').padEnd(12)} v${r.version} features=${r.featureCount} status=${r.status} (${r.durationMs}ms)`,
          );
        }
        if (r.status === 'success') ok++;
        else if (r.status === 'partial') partial++;
        else if (r.status === 'noop') noop++;
        else if (r.status === 'unchanged') unchanged++;
        else if (r.status === 'refreshed') refreshed++;
        else failed++;
      } catch (err) {
        failed++;
        logger.error(`  ${p.externalId} FAILED: ${err instanceof Error ? err.message : err}`);
      }
      if (!verbose && ++done % 2000 === 0) logger.log(`  …进度 ${done}/${total} (failed=${failed})`);
    };

    // ⭐ worker pool 替代原「每 N 个一批 await Promise.all」的栅栏(实测效率只有 55%,
    //   缺口=等本批最慢那个)。口径与 runPool 注释同源。
    await runPool(patients, args.concurrency, (p) => runOne(p));

    logger.log('─────────────────────────────────');
    logger.log(
      `Done: success=${ok} partial=${partial} refreshed=${refreshed} ` +
        `unchanged=${unchanged} noop=${noop} failed=${failed}`,
    );

    // ⭐ 删「上次医生 / 偏好医生」候选名单的缓存 —— 重算顺路刷新了 patient_profiles 的医生列,
    //   缓存里那份就旧了。TTL 已收到 10 分钟(见 plan.service.ts),这里再删一次是为了**即时可见**:
    //   刚跑完重算就去筛选面板搜医生,不该还要等 10 分钟。
    //   2026-07-29 测试服的坑:三列刚上线全是 NULL,第一次开面板缓存了 `[]`,后来重算填了值,
    //   前端照旧「暂无医生名单」——表现成"这医生搜不到",没人会想到是 Redis。
    //   失败只告警:名单缓存过期本来就只是慢一点,不该让一趟几小时的重算以非零码收场。
    try {
      const redis = app.get(RedisService);
      const tenants = await prisma.patient.findMany({
        where: { hostId: host.id },
        select: { tenantId: true },
        distinct: ['tenantId'],
      });
      for (const t of tenants) await redis.del(doctorOptionsCacheKey(host.id, t.tenantId));
      logger.log(`医生名单缓存已清:${tenants.length} 个 tenant`);
    } catch (err) {
      logger.warn(`医生名单缓存清理失败(不影响重算结果):${err instanceof Error ? err.message : err}`);
    }
  } catch (err) {
    new Logger('recompute-persona').error(err instanceof Error ? err.stack : String(err));
    process.exitCode = 2;
  } finally {
    await app.close();
  }
}

bootstrap().catch((err) => {
  console.error(err);
  process.exit(3);
});
