/**
 * Golden set 跑批 —— **量的是通过率，不是通过/不通过**。
 *
 * ═══ 为什么必须是通过率 ═══════════════════════════════════════════
 * 这一层的被测对象是**模型行为**，它天生带方差。2026-08-15 那天为了定一句话，
 * 同一份提示词的四种写法各跑三遍才分得出好坏；而「引导节点逐组穿插」这条
 * 在四次运行里只对了两次 —— **单跑一次什么都证明不了**，绿了也可能是运气。
 * ⇒ 每条用例跑 N 轮，输出「N 轮里过了几轮」。改提示词/换模型前后各跑一次，比的是这个数。
 * ⛔ 别把它接进 CI：它真的调模型（花钱、慢、有波动），红一次不代表代码坏了。
 *
 * ═══ 判的是动作，不是文字 ═══════════════════════════════════════
 * 判据全部来自 `tests/golden/assignment-golden.ts`（那边有完整的理由）：
 * 工具调用集合 + 少数几个「说了就造成真实损失」的词。
 * ⛔ 别加"措辞好不好"的断言：同一个意思十种说法都对，锁了只会让每次合理改写变红。
 *
 * ═══ 打谁 ═══════════════════════════════════════════════════════
 * 打**真的 HTTP 端点**（默认 localhost:3101），不 mock 任何一层 ——
 * 提示词装配、工具清单、MCP、取数全是线上那条路。
 * ⚠️ 所以它要求本地服务在跑、数据库有数；跑之前先确认 `pnpm --filter @pac/service dev`。
 *
 * ⚠️ 它住在 `tests/` 而不是 `src/cli/`：用例数据在这儿，而主 tsconfig 的 rootDir 是 `src`，
 *   从 src 反向 import tests 会 TS6059。放这边由 `tsconfig.typecheck.json` 一并类型检查
 *   （那份的 include 含 tests/），jest 也不会把它当用例收走（只收 `*.spec.ts`）。
 *
 * 用法：
 *   pnpm --filter @pac/service golden                  # 全部用例 × 3 轮
 *   pnpm --filter @pac/service golden -- --rounds 5
 *   pnpm --filter @pac/service golden -- --case propose-directly
 *   pnpm --filter @pac/service golden -- --out tests/golden/snapshots/before.json
 *
 * 环境变量（都有默认值，本地一般不用设）：
 *   GOLDEN_BASE_URL / GOLDEN_TENANT / GOLDEN_CLINIC_ID / GOLDEN_MODEL
 */
import { writeFileSync, mkdirSync } from 'node:fs';
import { dirname } from 'node:path';
import type { SheetSnapshot } from '@pac/types';
import { GOLDEN_CASES, type GoldenCase } from './assignment-golden';

const BASE = process.env.GOLDEN_BASE_URL ?? 'http://localhost:3101';
const TENANT = process.env.GOLDEN_TENANT ?? 'ruitai';
/** 拿来跑的那家诊所 —— 要有足够的候选人，否则 propose 出空提案，用例全部失真 */
const CLINIC = process.env.GOLDEN_CLINIC_ID ?? 'e83d432a38bb4f6284713b36db4e7497';

interface Turn {
  /// ⚠️ 连**入参**一起收 —— 只收名字的话，"调了但少带一格参数"这一类整个测不到
  calls: Array<{ tool: string; args: Record<string, unknown> }>;
  /**
   * ⚠️ 事件的**先后**（`'text'` / 工具名）—— 只记序列，不记内容。
   *   「可动手的事就地开放」这条设计的判据是"每组按钮前面有没有正文"，
   *   而那件事**只存在于先后里**：三次调用一次不少，全堆在正文之前一样是错的
   *   （实测抓到过 `PGGG·S·` 这种形状）。⛔ 只看 calls 数组是看不见它的。
   */
  seq: Array<'text' | string>;
  text: string;
  error: string | null;
}

/** 一轮的判定结果 —— ⚠️ 失败要带上"差在哪"，否则跑完只看到一个 0/3 无从下手 */
interface RoundResult {
  ok: boolean;
  missing: string[];
  forbidden: string[];
  said: string[];
  /// 参数对不上的：`工具.参数 期望X 实际Y`
  badArgs: string[];
  /// 先后错了的
  outOfOrder: string[];
  /// 该"讲完再开"却直接开了的
  notInPlace: string[];
  tools: string[];
  error: string | null;
}

function arg(name: string, fallback?: string): string | undefined {
  const i = process.argv.indexOf(`--${name}`);
  return i >= 0 ? process.argv[i + 1] : fallback;
}

/**
 * 按身份取 token —— **主管和客服是两套工具清单**，用例声明 `who` 决定拿哪一套。
 * ⚠️ 同一身份只登一次（缓存）：每条用例各登一次没有意义，还会拖慢跑批。
 */
const tokens = new Map<string, string>();
async function login(c: GoldenCase): Promise<string> {
  const who = c.who ?? 'leader';
  const key = `${who}:${c.asUser ?? ''}`;
  const cached = tokens.get(key);
  if (cached) return cached;

  const body =
    who === 'staff'
      ? // ⚠️ 带真实客服 id：通用 mock 身份名下没有单，「今天该联系谁」会退化成空队列
        { tenant: TENANT, role: 'staff', ...(c.asUser ? { userExternalId: c.asUser } : {}) }
      : // ⭐ 集团级：单诊所 leader 的 scope 未必包含 CLINIC，那会让每条用例都死在越权闸上
        { tenant: TENANT, role: 'leader', orgLevel: 'group' };

  const res = await fetch(`${BASE}/pac/v1/auth/mock-login`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(body),
  });
  const json = (await res.json()) as { data?: { accessToken?: string }; msg?: string };
  const token = json.data?.accessToken;
  if (!token) throw new Error(`mock-login 失败（${key}）：${json.msg ?? res.status}`);
  tokens.set(key, token);
  return token;
}

/**
 * 把一条用例跑一轮。
 *
 * ⚠️ `given`（"这一轮之前已经发生过什么"）用**助手消息**表达 —— 那正是真实链路
 *   注入上下文的方式：主管点确认、界面改单，都是 `appendAssistantNote` 往消息流
 *   塞一条助手文本，模型下一轮就是从那里知道"刚才发生了什么"。
 *   ⛔ 别改成 system 追加：那会让用例走一条线上不存在的路。
 */
/**
 * 用例声明 `sheet` 时随请求捎的那份草稿快照。
 *
 * ⚠️ 数字**故意平淡**：用例判的是动作，不是这几个数。真正起作用的只有
 *   「有没有这张单」—— `get_current_sheet` 据此回"现在什么样"还是"根本没有"。
 * ⛔ 别把它做成"每条用例自己编一套数"：那会让用例开始依赖具体数字，
 *   而那些数一改，红的是用例不是产品。
 */
const DEFAULT_SHEET: SheetSnapshot = {
  requestId: 'golden-req',
  confirmed: false,
  placed: 120,
  agents: 8,
  pending: 0,
  dropped: 0,
  moved: 0,
  expiresInDays: 1,
  rowExpiryOverrides: 0,
  benefit: null,
  byAgent: [
    { name: '张悦', count: 15 },
    { name: '李莉', count: 15 },
  ],
  /**
   * ⚠️ 人群条件用**真实存在的一格**（拔牙 · 3 年以上）：用例要测"重算带不带回条件"，
   *   而带回一个不存在的格子会圈出 0 人，测出来的是另一件事。
   */
  criteria: {
    potentialTreatment: 'extraction',
    temperature: 'cold_over',
    narrowedBy: null,
    candidateTotal: 2521,
    batchSize: 495,
  },
};

async function runOnce(c: GoldenCase, token: string): Promise<Turn> {
  const messages = [
    ...(c.given ?? []).map((g) => ({ role: 'assistant' as const, content: g })),
    { role: 'user' as const, content: c.say },
  ];
  const res = await fetch(`${BASE}/pac/v1/assistant/chat`, {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      Accept: 'text/event-stream',
      Authorization: `Bearer ${token}`,
    },
    body: JSON.stringify({
      messages,
      // ⚠️ 客服侧没有诊所选择器 —— ⛔ 别给它塞 activeClinicId，那是主管那一屏才有的上下文
      ...(c.who === 'staff' ? {} : { activeClinicId: CLINIC }),
      // ⭐ 声明了 sheet 的用例才送 —— 没声明就是"他手上确实没有单",那也是要测的状态
      ...(c.sheet ? { sheetState: { ...DEFAULT_SHEET, ...c.sheet } } : {}),
      ...(process.env.GOLDEN_MODEL ? { model: process.env.GOLDEN_MODEL } : {}),
    }),
  });
  if (!res.ok || !res.body) return { calls: [], seq: [], text: '', error: `HTTP ${res.status}` };

  const calls: Turn['calls'] = [];
  const seq: Turn['seq'] = [];
  let text = '';
  let error: string | null = null;
  const reader = res.body.pipeThrough(new TextDecoderStream()).getReader();
  let buf = '';
  for (;;) {
    const { value, done } = await reader.read();
    if (done) break;
    buf += value;
    let sep: number;
    while ((sep = buf.indexOf('\n\n')) !== -1) {
      const frame = buf.slice(0, sep);
      buf = buf.slice(sep + 2);
      const line = frame.split('\n').find((l) => l.startsWith('data: '));
      if (!line) continue;
      try {
        const e = JSON.parse(line.slice(6)) as Record<string, unknown>;
        if (e.type === 'tool_call') {
          calls.push({
            tool: String(e.tool),
            args: (e.args ?? {}) as Record<string, unknown>,
          });
          seq.push(String(e.tool));
        }
        else if (e.type === 'text' || e.type === 'text-delta') {
          const t = String(e.text ?? '');
          text += t;
          // ⚠️ 连续的正文只记一次 —— 记的是"这儿有段正文",不是它有多长
          if (t.trim() && seq[seq.length - 1] !== 'text') seq.push('text');
        }
        else if (e.type === 'error') error = String(e.error);
      } catch {
        /* 半帧，忽略 */
      }
    }
  }
  return { calls, seq, text, error };
}

function judge(c: GoldenCase, t: Turn): RoundResult {
  const tools = t.calls.map((x) => x.tool);
  const called = new Set(tools);
  const missing = c.mustCall.filter((x) => !called.has(x));
  const forbidden = (c.mustNotCall ?? []).filter((x) => called.has(x));
  /**
   * ⚠️ `mustNotSay` **只在没调到工具时才判** —— 用例集里写着这条纪律：
   *   工具真调了，它说「已撤销」就是事实；没调却这么说才是事故。
   */
  const said = missing.length > 0 ? (c.mustNotSay ?? []).filter((w) => t.text.includes(w)) : [];
  /**
   * 参数判定 —— ⚠️ 只看用例点名的那几个键，其余**一律不管**。
   * ⚠️ 同一个工具被调多次时，**任意一次对上就算过**（重出一版、再微调，都是合理的）。
   */
  const badArgs: string[] = [];
  for (const [tool, want] of Object.entries(c.mustCallWith ?? {})) {
    const got = t.calls.filter((x) => x.tool === tool);
    if (got.length === 0) continue; // 没调到 → 已经记在 missing 里，⛔ 别报两遍
    for (const [k, v] of Object.entries(want)) {
      if (!got.some((g) => JSON.stringify(g.args[k]) === JSON.stringify(v))) {
        badArgs.push(`${tool}.${k} 期望 ${JSON.stringify(v)}，实际 ${got.map((g) => JSON.stringify(g.args[k])).join(' / ')}`);
      }
    }
  }
  /**
   * 先后判定 —— **子序列**：按用例给的次序在实际调用里往前找，找得到就继续。
   * ⚠️ 中间插了别的调用不算错（多查一次是合理的），⛔ 只有"该在前面的跑到后面去了"才判红。
   */
  const outOfOrder: string[] = [];
  {
    let cursor = 0;
    for (const want of c.mustCallInOrder ?? []) {
      const at = t.calls.findIndex(
        (x, i) =>
          i >= cursor &&
          x.tool === want.tool &&
          Object.entries(want.args ?? {}).every(
            ([k, v]) => JSON.stringify(x.args[k]) === JSON.stringify(v),
          ),
      );
      if (at < 0) {
        outOfOrder.push(
          `${want.tool}${want.args ? `(${Object.values(want.args).join(',')})` : ''} 没有出现在它该在的位置`,
        );
        break; // ⛔ 后面几步不再判:第一处错位之后的判定没有意义
      }
      cursor = at + 1;
    }
  }
  /**
   * 「就地开放」判定 —— 这一步的**紧邻前一个事件必须是正文**。
   * ⚠️ 判的是"讲完这段才开这组"，⛔ 不是"整轮里有没有正文"：
   *   `PGGG·S·`（三组按钮全堆在开头、正文在最后）整轮正文一大段，照样是错的。
   */
  const notInPlace: string[] = [];
  for (const want of c.mustCallAfterText ?? []) {
    const at = t.calls.findIndex(
      (x) =>
        x.tool === want.tool &&
        Object.entries(want.args ?? {}).every(
          ([k, v]) => JSON.stringify(x.args[k]) === JSON.stringify(v),
        ),
    );
    if (at < 0) continue; // 没调到 → 已记在 missing
    // 在 seq 里定位这次调用：数到第 at+1 个同名工具
    let seen = 0;
    const idx = t.seq.findIndex((e) => e === want.tool && seen++ === at);
    if (idx <= 0 || t.seq[idx - 1] !== 'text') {
      notInPlace.push(
        `${want.tool}${want.args ? `(${Object.values(want.args).join(',')})` : ''} 前面不是正文（实际形状 ${t.seq
          .map((e) => (e === 'text' ? '·' : e === 'show_guidance' ? 'G' : e === 'show_sheet' ? 'S' : 'T'))
          .join('')}）`,
      );
    }
  }
  return {
    ok:
      !t.error &&
      missing.length === 0 &&
      forbidden.length === 0 &&
      said.length === 0 &&
      badArgs.length === 0 &&
      outOfOrder.length === 0 &&
      notInPlace.length === 0,
    missing,
    forbidden,
    said,
    badArgs,
    outOfOrder,
    notInPlace,
    tools,
    error: t.error,
  };
}

async function main(): Promise<void> {
  const rounds = Number(arg('rounds', '3'));
  const only = arg('case');
  const out = arg('out');
  const cases = only ? GOLDEN_CASES.filter((c) => c.id === only) : GOLDEN_CASES;
  if (cases.length === 0) throw new Error(`没有这条用例：${only}`);

  console.log(`golden: ${cases.length} 条 × ${rounds} 轮 → ${BASE}\n`);

  const report: Array<{ id: string; pass: number; rounds: number; fails: RoundResult[] }> = [];
  for (const c of cases) {
    const results: RoundResult[] = [];
    const token = await login(c);
    for (let i = 0; i < rounds; i++) {
      // ⚠️ 串行 —— ⛔ 别并发：同一个 requestId 的幂等、以及模型侧限流都会让并发结果不可比
      results.push(judge(c, await runOnce(c, token)));
    }
    /**
     * 🔴 **接口错误 ⛔ 不算模型失败**（2026-08-15 吃过亏）。
     *   那天跑批跑到一半，我在旁边改了服务端源码 —— dev server 重启，请求全断
     *   （`terminated` / `fetch failed`）。而当时的 runner 把它算成"这条用例没通过"，
     *   输出读起来**和模型退化一模一样**。
     * ⇒ 分开数：接口错误单列，且**不进通过率的分母** —— 一次环境故障不该让人去改提示词。
     */
    const errored = results.filter((r) => r.error);
    const judged = results.filter((r) => !r.error);
    const pass = judged.filter((r) => r.ok).length;
    const fails = judged.filter((r) => !r.ok);
    report.push({ id: c.id, pass, rounds: judged.length, fails });
    const bar =
      judged.length === 0 ? '🔌' : pass === judged.length ? '✅' : pass === 0 ? '❌' : '⚠️ ';
    console.log(
      `${bar} ${String(pass)}/${judged.length}  ${c.id}${c.who === 'staff' ? ' 〔客服〕' : ''}` +
        (errored.length ? `   🔌 另有 ${errored.length} 轮接口错误(不计入)` : ''),
    );
    if (errored.length) {
      console.log(`      ⚠️ ${errored[0]!.error} —— 服务是不是中途重启了？改服务端源码会触发`);
    }
    for (const f of fails.slice(0, 1)) {
      if (f.error) console.log(`      报错: ${f.error}`);
      if (f.missing.length) console.log(`      没调到: ${f.missing.join(', ')}`);
      if (f.forbidden.length) console.log(`      不该调却调了: ${f.forbidden.join(', ')}`);
      if (f.said.length) console.log(`      不该说却说了: ${f.said.join(', ')}`);
      for (const b of f.badArgs) console.log(`      参数不对: ${b}`);
      for (const o of f.outOfOrder) console.log(`      先后不对: ${o}`);
      for (const o of f.notInPlace) console.log(`      没就地开放: ${o}`);
      console.log(`      实际调用: ${f.tools.join(' → ') || '(一个都没调)'}`);
    }
  }

  const total = report.reduce((s, r) => s + r.pass, 0);
  const max = report.reduce((s, r) => s + r.rounds, 0);
  if (max === 0) {
    console.log('\n🔌 **一轮都没判成** —— 全是接口错误，本次结果作废，⛔ 别据此下任何结论。');
    process.exit(1);
  }
  console.log(`\n合计 ${total}/${max}（${((total / max) * 100).toFixed(0)}%）`);
  /**
   * ⚠️ 样本太小时**明说**：3 轮里红一次是 33%，看着像"判不准"，实际可能是 90% 的尾巴。
   *   2026-08-15 就这么误判过一次 —— 3 轮的 1/3 与 10 轮的 9/10 是同一个行为。
   */
  if (rounds < 5) {
    console.log(`⚠️ 每条只跑了 ${rounds} 轮 —— 这个数**分不出**"判不准"和"运气差"，⛔ 别据此改提示词。`);
  }

  if (out) {
    mkdirSync(dirname(out), { recursive: true });
    // ⚠️ 快照只落**判定结果**，⛔ 不落模型正文：正文每次都不一样，diff 出来全是噪音
    writeFileSync(
      out,
      JSON.stringify(
        { rounds, total, max, cases: report.map(({ id, pass }) => ({ id, pass })) },
        null,
        2,
      ),
    );
    console.log(`快照 → ${out}`);
  }
}

void main().catch((e: unknown) => {
  console.error(e instanceof Error ? e.message : e);
  process.exit(1);
});
