import type { AssignmentProposal, SheetEditOp } from '@pac/types';

/**
 * chat-blocks —— 助手消息流的**数据模型 + 纯函数操作**。
 *
 * 从 `use-assistant-chat` 抽出来的原因：草稿状态机（active / superseded / …）
 * 是这条链路上最容易出错、也最值得回归的一段逻辑，而它原来内联在 React hook 里，
 * 只能靠端到端点一遍来验。抽成纯函数之后可以直接测。
 *
 * ⛔ 这里只放**不碰 React、不碰网络**的东西。带副作用的仍留在 hook 里。
 */

/** 一步工具调用(Claude 式透明步骤:看到调了哪个工具、入参、返回数据)。 */
export interface ToolStep {
  id: string;
  /** 后端 toolCallId,用于把 tool_result/tool_error 精确匹配回对应步骤(支持并行/多调用)。 */
  callId?: string;
  tool: string;
  args: unknown;
  result?: unknown;
  status: 'running' | 'done' | 'error';
  error?: string;
}

/** 模型产出的 HTML 卡片(在输出区沙箱 iframe 渲染)。 */
export interface Artifact {
  id: string;
  title?: string;
  html: string;
  /** true = 还在流式生成中(只注入内容,图表脚本不执行);false/缺省 = 最终(执行图表脚本)。 */
  streaming?: boolean;
}

/**
 * 草稿状态机 —— **任一时刻最多只有一张 `active`**。
 *
 * ⭐ `superseded` = 主管让助手重出了一版，这张当场作废。
 *   🔴 作废发生在**新版生成时**，⛔ 不是在确认时（产品定，2026-08-12）：
 *   否则主管面前会同时摆着两张都能点确认的卡，点错就是分错一批人。
 * ⚠️ 作废的卡**继续留在对话里并显式标注**，⛔ 不许静默变灰、⛔ 不许消失 ——
 *   他要能看见自己出过几版、以及哪一版才是当前的。
 * ⚠️ `confirmed` / `cancelled` 是**终态**，⛔ 不许被后续的作废扫描回退。
 */
export type DraftState = 'active' | 'superseded' | 'confirmed' | 'cancelled';

export type Block =
  /**
   * 文本块。`modelText` = **只给模型看**的版本(缺省时模型看 `text`)。
   *
   * 🔴 为什么需要两份:确认后注入的那句话里要带**完整批次 uuid**,否则模型撤销时
   *   只能拿界面上的 8 位短号去调 `revoke_assignment` —— 实测必然报「批次不存在」。
   *   但 uuid 对主管毫无意义,摆在对话里纯属噪音。⇒ 显示一份、喂模型一份。
   * ⚠️ 用它承载的必须是**同一件事的两种措辞**,⛔ 不许塞模型专属的隐藏指令:
   *   主管看不见的话就无法纠正,那是"界面替主管说话"。
   */
  | { kind: 'text'; text: string; modelText?: string }
  | { kind: 'tool'; step: ToolStep }
  | { kind: 'artifact'; artifact: Artifact }
  /**
   * 全景确认单 —— **原生 React 组件**,不是 artifact。
   *
   * ⚠️ 为什么不能用 artifact:artifact 跑在 `sandbox="allow-scripts"` 的 iframe 里,
   * CSP 是 `connect-src 'none'` —— **卡片内不可能发出写请求**。确认单要能点确认落库,
   * 只能是原生组件。
   * 分岔记牢:**只读展示用 artifact,可交互用原生组件。**
   *
   * ⚠️ sheet 由**服务端经侧信道推来**,不是模型生成的 —— 它含几百个 planId,
   * 让模型吐等于让它编。
   */
  | {
      kind: 'assignment_sheet';
      requestId: string;
      sheet: AssignmentProposal;
      state: DraftState;
      /** 确认后的批次 id(终态展示用) */
      assignmentId?: string;
      /**
       * ⭐ 助手下发的**语义编辑指令队列**(edit_assignment_sheet 走侧信道推来)。
       * 卡片组件按 `seq` 只执行新增的那一批,执行完把结果回一句进对话。
       * ⚠️ 用队列而不是"最新一条":主管连着说两句时,两批指令都要生效。
       */
      edits?: { seq: number; ops: SheetEditOp[] }[];
    };

export interface ChatMessage {
  id: string;
  role: 'user' | 'assistant';
  blocks: Block[];
}

export type ChatStatus = 'idle' | 'streaming' | 'error';

// ─────────────────────────────────────────────────────────
// 纯函数操作
// ─────────────────────────────────────────────────────────

/** 把 tool_result/tool_error 匹配回对应工具步骤:优先按 callId,退化到"最后一个 running"。 */
export function findToolIdx(blocks: Block[], callId: string | undefined): number {
  if (callId) {
    const i = blocks.findIndex((b) => b.kind === 'tool' && b.step.callId === callId);
    if (i !== -1) return i;
  }
  for (let i = blocks.length - 1; i >= 0; i--) {
    const b = blocks[i];
    if (b && b.kind === 'tool' && b.step.status === 'running') return i;
  }
  return -1;
}

/** 按 callId upsert artifact 块:流式 html 增量更新,最终 tool_call 覆盖(title+完整 html)。 */
export function upsertArtifact(
  blocks: Block[],
  id: string,
  patch: { title?: string; html?: string; streaming?: boolean },
): Block[] {
  const i = blocks.findIndex((b) => b.kind === 'artifact' && b.artifact.id === id);
  if (i === -1) {
    return [
      ...blocks,
      {
        kind: 'artifact',
        artifact: { id, title: patch.title, html: patch.html ?? '', streaming: patch.streaming },
      },
    ];
  }
  return blocks.map((b, j) => {
    if (j !== i || b.kind !== 'artifact') return b;
    return {
      kind: 'artifact',
      artifact: {
        id,
        title: patch.title ?? b.artifact.title,
        html: patch.html ?? b.artifact.html,
        streaming: patch.streaming ?? b.artifact.streaming,
      },
    };
  });
}

/**
 * 新草稿到达 —— upsert 它，并把**此前所有 `active` 的当场作废**。
 *
 * 🔴 必须**跨消息**扫：上一版几乎总在**上一轮**的消息里
 *   （主管看着它才说「重排一版」的）。只扫当前消息永远扫不到，而且不会报错。
 * ⚠️ 只作废 `active`：`confirmed` / `cancelled` 是终态，⛔ 不许回退。
 * ⚠️ 同一 `requestId` 重推时清空 `edits` —— 旧的编辑指令对新名单没有意义。
 *
 * @param hostMessageId 新卡挂到哪条消息下（通常是当前这条助手消息）
 */
export function applyIncomingSheet(
  messages: ChatMessage[],
  input: { requestId: string; sheet: AssignmentProposal; hostMessageId: string },
): ChatMessage[] {
  const { requestId, sheet, hostMessageId } = input;
  let placed = false;
  const swept = messages.map((m) => ({
    ...m,
    blocks: m.blocks.map((b): Block => {
      if (b.kind !== 'assignment_sheet') return b;
      if (b.requestId === requestId) {
        placed = true;
        return { ...b, sheet, state: 'active', edits: [] };
      }
      return b.state === 'active' ? { ...b, state: 'superseded' } : b;
    }),
  }));
  if (placed) return swept;
  return swept.map((m) =>
    m.id === hostMessageId
      ? { ...m, blocks: [...m.blocks, { kind: 'assignment_sheet', requestId, sheet, state: 'active' }] }
      : m,
  );
}

/**
 * 找**当前有效**的那张确认单（唯一的 `active`）。
 *
 * ⭐ 2026-08-12 由「最后一张」改成「唯一 active」：重出一版之后，最后一张可能
 *   正是刚作废的那张 —— 助手的修改指令打上去，主管一点反应都看不到。
 * ⚠️ 模型手里没有 requestId（服务端铸的，没进它上下文），它说「这批」指的就是当前有效那张。
 */
export function findActiveSheet(
  messages: ChatMessage[],
): { messageIndex: number; blockIndex: number } | null {
  for (let mi = messages.length - 1; mi >= 0; mi--) {
    const blocks = messages[mi]?.blocks ?? [];
    const bi = blocks.findIndex((b) => b.kind === 'assignment_sheet' && b.state === 'active');
    if (bi >= 0) return { messageIndex: mi, blockIndex: bi };
  }
  return null;
}

// ─────────────────────────────────────────────────────────
// 引导节点的 intent 路由
// ─────────────────────────────────────────────────────────

/**
 * intent → 卡片上的语义编辑指令。
 *
 * ⭐ **按钮和助手走的是同一条队列**：助手的 `edit_assignment_sheet` 推的是
 *   `SheetEditOp[]`，这里把按钮点击也翻成同一种 op 压进去。
 *   ⛔ 别为按钮另写一套执行路径 —— 两条路各做各的，行为必然漂，而漂了不报错。
 *
 * 返回 `null` = 这个 intent 不是局部改单能完成的（要重跑算法），交给 `intentToPrompt`。
 */
export function intentToSheetOps(
  intent: string,
  args?: Record<string, unknown>,
): SheetEditOp[] | null {
  switch (intent) {
    case 'pending.to_owner':
      // ⚠️ owner 与 balance **结果正好相反**，选错主管一眼看得出来（实测栽过：
      //   18 个人被散给了 17 位别人）。按钮这条路没有理解环节，所以不会选错。
      return [{ select: { group: 'pending' }, action: 'assign', to: { mode: 'owner' } }];
    case 'pending.spread':
      return [{ select: { group: 'pending' }, action: 'assign', to: { mode: 'balance' } }];
    case 'pending.remove':
      return [{ select: { group: 'pending' }, action: 'remove' }];
    case 'expiry.set':
      return typeof args?.days === 'number'
        ? [{ select: { group: 'batch' }, action: 'set_expiry', days: args.days }]
        : null;
    default:
      return null;
  }
}

/**
 * intent → 替主管说的那句话（交给助手重跑算法）。
 *
 * ⚠️ 这几个都要**换人群或换基数**，必须重出一版，⛔ 不能用局部改单去凑
 *   （凑出来的单子人群没变，主管以为条件生效了、其实没有 —— 静默错）。
 * ⚠️ 措辞用**主管会说的话**，⛔ 不带 intent id、⛔ 不带字段名 ——
 *   这句会原样出现在对话里，他要能认出这是自己刚点的那一下。
 */
export function intentToPrompt(
  intent: string,
  args?: Record<string, unknown>,
): string | null {
  switch (intent) {
    case 'cohort.narrow':
      return typeof args?.personaTags === 'string'
        ? `这批只圈符合「${args.personaTags}」的人，重新排一版`
        : null;
    case 'cohort.widen':
      return '往后放一档再看看，或者不限时间档，重新排一版';
    case 'anchor.switch':
      return '换成按诊断距今的口径，重新排一版';
    case 'batch_size.set':
      return '这批人数减少一些，重新排一版';
    case 'daily_rate.set':
      return '每人每天按更少的通数算，重新排一版';
    default:
      return null;
  }
}

// ─────────────────────────────────────────────────────────
// 历史回传（分层）
// ─────────────────────────────────────────────────────────

/** 单条工具记录回传时的长度上限 —— 超出即截断并标注。 */
const HISTORY_CAP = { args: 120, result: 220 } as const;

/**
 * 只取错误的**第一行、且切掉堆栈**。
 *
 * ⚠️ ⛔ 光靠长度截不住：堆栈常常就在前 120 字里（实测本模块的回归就是这么红的）。
 *   要按**结构**剥 —— 换行之后、` at ` 之后的都是给开发者看的，进历史纯属污染。
 */
function errorReason(raw: string | undefined): string {
  const first = (raw ?? '未知错误').split('\n')[0] ?? '未知错误';
  const cut = first.split(/\s+at\s+/)[0] ?? first;
  return cut.trim() || '未知错误';
}

function clip(s: string, max: number): string {
  const t = s.replace(/\s+/g, ' ').trim();
  return t.length <= max ? t : `${t.slice(0, max)}…（略）`;
}

/**
 * 一次工具调用 → 一行**摘要**。
 *
 * ⚠️ 摘要不是省钱，是**保留脉络又不灌垃圾**：
 *   · 全量回传 → 确认单那类载荷几百个 uuid，贵且诱导模型去复述；
 *   · 一点不传 → 模型下一轮不知道自己查过什么，会重复调用，
 *     而且只能拿**自己上一轮说过的话**当唯一依据 —— 那正是"背出成品句子"的温床。
 * ⚠️ 失败只留错因，⛔ 不带原始堆栈（脏东西进了历史就一直在，除非压缩）。
 */
export function summarizeToolStep(step: ToolStep): string {
  const args = step.args == null ? '' : clip(JSON.stringify(step.args), HISTORY_CAP.args);
  const head = `〔调用 ${step.tool}${args && args !== '{}' ? ` ${args}` : ''}`;
  if (step.status === 'error') return `${head} → 失败：${clip(errorReason(step.error), 120)}〕`;
  if (step.status === 'running') return `${head} → 未完成〕`;
  const raw = typeof step.result === 'string' ? step.result : JSON.stringify(step.result ?? '');
  return `${head} → ${clip(raw, HISTORY_CAP.result)}〕`;
}

/**
 * 把一条消息压平成后端要的 `{role, content}`。
 *
 * 🔴 2026-08-12（P3）：**工具记录现在也回传**（分层摘要）。
 *   在此之前只回传文本块，注释写的是"工具块不回传,模型自行重新决策" ——
 *   代价是模型每一轮都从零开始：重复调同一个工具，且拿不到上一轮的任何锚。
 * ⚠️ 按 blocks 的**原始顺序**拼，⛔ 别把工具记录都堆到末尾：
 *   「先查了什么、再说了什么」本身就是脉络。
 */
export function toApiMessage(
  m: ChatMessage,
): { role: 'user' | 'assistant'; content: string } | null {
  const parts = m.blocks
    .map((b): string => {
      switch (b.kind) {
        // ⭐ modelText 优先 —— 界面显示"批次已确认"，模型拿到的那份还带着完整 uuid
        case 'text':
          return b.modelText ?? b.text;
        case 'tool':
          return `\n${summarizeToolStep(b.step)}\n`;
        // ⚠️ 卡片内容不回传：它是渲染产物，模型复述它没有意义（而且很长）
        case 'artifact':
          return `\n〔已渲染卡片${b.artifact.title ? `：${b.artifact.title}` : ''}〕\n`;
        // ⚠️ 确认单的事实在同一轮的 tool 摘要里已经有了，这里只留一个"它存在"的锚
        case 'assignment_sheet':
          return `\n〔确认单 ${b.state === 'active' ? '当前有效' : b.state === 'confirmed' ? '已确认' : b.state === 'superseded' ? '已作废' : '已撤销'}〕\n`;
        default:
          return '';
      }
    })
    .join('')
    .replace(/\n{3,}/g, '\n\n')
    .trim();
  if (!parts) return null;
  return { role: m.role, content: parts };
}
