'use client';

import { useCallback, useRef, useState } from 'react';
import type { AssignmentProposal, SheetEditOp } from '@pac/types';
import { env } from '@/lib/env';
import { emitPetEvent } from '@/lib/pet-events';
import { useAuthStore } from '@/stores/auth-store';

/** 一步工具调用(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;
}

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: 'pending' | 'confirmed' | 'cancelled';
      /** 确认后的批次 id(终态展示用) */
      assignmentId?: string;
      /**
       * ⭐ 助手下发的**语义编辑指令队列**(edit_assignment_sheet 走侧信道推来)。
       * 卡片组件按 `seq` 只执行新增的那一批,执行完把结果回一句进对话。
       * ⚠️ 用队列而不是"最新一条":主管连着说两句时,两批指令都要生效。
       */
      edits?: { seq: number; ops: SheetEditOp[] }[];
    };

/**
 * 从 revoke_assignment 的返回里抠出 assignmentId。
 * ⚠️ MCP 工具的返回是 `{content:[{type:'text',text:'<json>'}]}` 这种包装,
 *   形状随实现变 —— 所以**宽容解析**:能拿到就用,拿不到就不同步(⛔ 别抛错,
 *   撤销本身已经成功了,前端同步失败不该让用户看到红字)。
 */
function extractRevokedId(result: unknown): string | null {
  try {
    const raw =
      typeof result === 'string'
        ? result
        : JSON.stringify(result ?? '');
    const m = raw.match(/"assignmentId"\s*:\s*"([0-9a-fA-F-]{36})"/);
    return m?.[1] ?? null;
  } catch {
    return null;
  }
}

/** render_artifact 是本地"渲染"工具,不当普通工具步骤展示,而是渲成卡片。 */
const ARTIFACT_TOOL = 'render_artifact';

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

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

let _id = 0;
const nextId = () => `m${Date.now()}_${_id++}`;

/** 把 tool_result/tool_error 匹配回对应工具步骤:优先按 callId,退化到"最后一个 running"。 */
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)。 */
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,
      },
    };
  });
}

/** 把一条消息压平成后端要的 {role, content} 文本(工具块不回传,模型自行重新决策)。 */
function toApiMessage(m: ChatMessage): { role: 'user' | 'assistant'; content: string } | null {
  const text = m.blocks
    .filter((b): b is Extract<Block, { kind: 'text' }> => b.kind === 'text')
    // ⭐ modelText 优先 —— 界面显示"批次已确认",模型拿到的那份还带着完整 uuid
    .map((b) => b.modelText ?? b.text)
    .join('')
    .trim();
  if (!text) return null;
  return { role: m.role, content: text };
}

/** 听写音频上传 → 文字(后端 /assistant/transcribe → 自部署 SenseVoice,PII 不出内网)。 */
export async function transcribeAudio(blob: Blob): Promise<string> {
  const token = useAuthStore.getState().accessToken;
  const form = new FormData();
  const ext = blob.type.includes('mp4') ? 'mp4' : blob.type.includes('ogg') ? 'ogg' : 'webm';
  form.append('file', blob, `dictation.${ext}`);
  const res = await fetch(new URL('/pac/v1/assistant/transcribe', env.apiBaseUrl), {
    method: 'POST',
    headers: token ? { Authorization: `Bearer ${token}` } : undefined,
    body: form,
  });
  if (!res.ok) throw new Error(`HTTP ${res.status}`);
  const json = (await res.json()) as { code?: number; msg?: string; data?: { text?: string } };
  if (json.code !== 0) throw new Error(json.msg || '识别失败');
  return json.data?.text ?? '';
}

export function useAssistantChat() {
  const [messages, setMessages] = useState<ChatMessage[]>([]);
  const [status, setStatus] = useState<ChatStatus>('idle');
  const [model, setModel] = useState('qwen');
  const abortRef = useRef<AbortController | null>(null);

  const stop = useCallback(() => {
    abortRef.current?.abort();
    abortRef.current = null;
    setStatus('idle');
  }, []);

  const send = useCallback(
    async (text: string) => {
      const trimmed = text.trim();
      if (!trimmed || status === 'streaming') return;

      const userMsg: ChatMessage = {
        id: nextId(),
        role: 'user',
        blocks: [{ kind: 'text', text: trimmed }],
      };
      const assistantId = nextId();
      const assistantMsg: ChatMessage = { id: assistantId, role: 'assistant', blocks: [] };

      // 历史(含新 user)→ apiMessages
      const apiMessages = [...messages, userMsg]
        .map(toApiMessage)
        .filter((x): x is { role: 'user' | 'assistant'; content: string } => x !== null);

      setMessages((prev) => [...prev, userMsg, assistantMsg]);
      setStatus('streaming');
      emitPetEvent({ type: 'ai_thinking_start' }); // 宠物演"思考"(详情页助手宠物)

      // 更新 assistant 消息 blocks 的小工具
      const patch = (fn: (blocks: Block[]) => Block[]) =>
        setMessages((prev) =>
          prev.map((m) => (m.id === assistantId ? { ...m, blocks: fn(m.blocks) } : m)),
        );

      const appendText = (t: string) =>
        patch((blocks) => {
          const last = blocks[blocks.length - 1];
          if (last && last.kind === 'text') {
            return [...blocks.slice(0, -1), { kind: 'text', text: last.text + t }];
          }
          return [...blocks, { kind: 'text', text: t }];
        });

      const onEvent = (evt: Record<string, unknown>) => {
        switch (evt.type) {
          case 'text':
            appendText((evt.text as string) ?? '');
            break;
          case 'assignment_sheet':
            // ⭐ 侧信道推来的确认单。upsert 按 requestId —— 模型若因某种原因重复调用,
            //   不该在对话里堆出两张卡片。
            patch((blocks) => {
              const rid = String(evt.requestId ?? '');
              const i = blocks.findIndex((b) => b.kind === 'assignment_sheet' && b.requestId === rid);
              const next: Block = {
                kind: 'assignment_sheet',
                requestId: rid,
                sheet: evt.sheet as AssignmentProposal,
                state: 'pending',
              };
              if (i >= 0) return [...blocks.slice(0, i), next, ...blocks.slice(i + 1)];
              return [...blocks, next];
            });
            break;
          case 'assignment_sheet_edit':
            // ⭐ 助手要改**已经呈现**的那张卡:指令挂到最后一张确认单上,由它自己去匹配执行。
            //
            // 🔴 必须**跨消息**找,⛔ 不能用 patch(那只改当前这条助手消息)——
            //   确认单几乎总是在**上一轮**的消息里(主管看着它才提的要求),
            //   只在当前消息里找永远找不到,而且不会报错、界面毫无反应。
            // ⚠️ 挂"最后一张"而不是按 requestId 找:主管说"把某某移出这批"指的就是眼前这张,
            //   而模型手里根本没有 requestId(服务端铸的,没进它上下文)。
            setMessages((prev) => {
              for (let mi = prev.length - 1; mi >= 0; mi--) {
                const bi = prev[mi]!.blocks.map((b) => b.kind).lastIndexOf('assignment_sheet');
                if (bi < 0) continue;
                const b = prev[mi]!.blocks[bi] as Extract<Block, { kind: 'assignment_sheet' }>;
                const next: Block = {
                  ...b,
                  edits: [
                    ...(b.edits ?? []),
                    { seq: (b.edits?.length ?? 0) + 1, ops: (evt.ops ?? []) as SheetEditOp[] },
                  ],
                };
                return prev.map((m, k) =>
                  k === mi ? { ...m, blocks: m.blocks.map((x, j) => (j === bi ? next : x)) } : m,
                );
              }
              return prev;
            });
            break;
          case 'artifact_html':
            // 流式:render_artifact 的 html 边生成边到(按 callId upsert,实时"长出"卡片)。
            patch((blocks) =>
              upsertArtifact(blocks, String(evt.id ?? ''), { html: String(evt.html ?? ''), streaming: true }),
            );
            break;
          case 'tool_call':
            if (evt.tool === ARTIFACT_TOOL) {
              // 渲染工具最终入参:覆盖为完整 html + title,标记 streaming=false(触发图表脚本执行)。
              const a = (evt.args ?? {}) as { title?: string; html?: string };
              patch((blocks) =>
                upsertArtifact(blocks, String(evt.id ?? nextId()), {
                  title: a.title,
                  html: a.html,
                  streaming: false,
                }),
              );
              break;
            }
            patch((blocks) => [
              ...blocks,
              {
                kind: 'tool',
                step: {
                  id: nextId(),
                  callId: evt.id ? String(evt.id) : undefined,
                  tool: String(evt.tool ?? '工具'),
                  args: evt.args,
                  status: 'running',
                },
              },
            ]);
            break;
          case 'tool_result':
            /**
             * ⭐ 助手撤销整批时,**把卡片也切到撤销态**。
             *
             * 🔴 不同步的话主管会看到两句互相矛盾的话:卡片写着「已分配」,
             *   助手说「已撤回 12 条」—— 这正是整个设计一直在防的"两处不一致听谁的"。
             * ⚠️ revoke_assignment 是 **MCP 工具**,推不了侧信道 ——
             *   但前端本来就收得到它的 tool_result,拿这个当同步信号,不必改架构。
             * ⚠️ 按 assignmentId 精确匹配,⛔ 别改"最后一张":主管可能连着分了两批,
             *   撤的是前一批。
             */
            if (evt.tool === 'revoke_assignment') {
              const rid = extractRevokedId(evt.result);
              if (rid) {
                setMessages((prev) =>
                  prev.map((m) => ({
                    ...m,
                    blocks: m.blocks.map((b) =>
                      b.kind === 'assignment_sheet' && b.assignmentId === rid
                        ? { ...b, state: 'cancelled' as const }
                        : b,
                    ),
                  })),
                );
              }
            }
            patch((blocks) => {
              const realIdx = findToolIdx(blocks, evt.id ? String(evt.id) : undefined);
              if (realIdx === -1) return blocks;
              const b = blocks[realIdx] as Extract<Block, { kind: 'tool' }>;
              const updated: Block = {
                kind: 'tool',
                step: { ...b.step, result: evt.result, status: 'done' },
              };
              return blocks.map((x, i) => (i === realIdx ? updated : x));
            });
            break;
          case 'tool_error':
            patch((blocks) => {
              const realIdx = findToolIdx(blocks, evt.id ? String(evt.id) : undefined);
              if (realIdx === -1) return blocks;
              const b = blocks[realIdx] as Extract<Block, { kind: 'tool' }>;
              const updated: Block = {
                kind: 'tool',
                step: { ...b.step, status: 'error', error: String(evt.error ?? '工具调用出错') },
              };
              return blocks.map((x, i) => (i === realIdx ? updated : x));
            });
            break;
          case 'error':
            appendText(`\n\n⚠️ 出错:${String(evt.error)}`);
            break;
          default:
            break;
        }
      };

      /**
       * 🔴🔴 **对账:说撤销了,就必须真调过工具。**
       *
       * 2026-08-03 实测(通义千问):主管说「撤销批次 #a7e1b6de」,助手回
       * 「已撤销批次:收回 9 条。」—— 而库里那批仍是 confirmed,9 条工单**一条没动**,
       * 卡片也还写着「已分配」。它**根本没调 revoke_assignment**,把成品话术**背了出来**。
       * (工具的返回值被设计成"成品句子、原话转述",模型学会形状后就能凭空生成。)
       *
       * 这比报错更危险:报错主管会重试,而"假成功"会让他**停止补救** ——
       * 他以为 9 个人回池了,实际还挂在客服名下,而 3 天后到期才会被发现。
       *
       * ⚠️ 这里**只陈述一个观察到的事实**(本轮没调过撤销工具),⛔ 不断言助手在撒谎 ——
       *   主管问「刚才那批撤了吗」时,助手如实回顾**也**不会调工具,那句回顾是对的。
       *   两种情形界面分不出来,所以两种都说得通的措辞才是诚实的。
       * ⚠️ 因此还要求**本轮主管确实在要求撤销**,把纯回顾挡在外面。
       * ⚠️ 判据用**工具事件**而不是"卡片状态",因为卡片可能压根不在本轮消息里。
       * ⛔ 不要改成"帮他补调一次工具" —— 撤销是写操作,不能由界面替主管做决定。
       */
      const assertRevokeActuallyHappened = () => {
        // 主管这一轮是不是在**要求**撤销(而不是在问结果)
        if (!/撤销|撤回|收回/.test(trimmed)) return;
        setMessages((prev) => {
          const last = prev[prev.length - 1];
          if (!last || last.role !== 'assistant') return prev;
          const said = last.blocks
            .filter((b): b is Extract<Block, { kind: 'text' }> => b.kind === 'text')
            .map((b) => b.text)
            .join('');
          // 只认"已经做完"的说法;「要我撤销吗」这类询问不算
          if (!/已撤销|撤销成功|已收回|已经撤回|已撤回/.test(said)) return prev;
          const called = last.blocks.some(
            (b) => b.kind === 'tool' && b.step.tool === 'revoke_assignment' && b.step.status === 'done',
          );
          if (called) return prev;
          return [
            ...prev.slice(0, -1),
            {
              ...last,
              blocks: [
                ...last.blocks,
                {
                  kind: 'text' as const,
                  text:
                    '\n\n⚠️ **这一轮助手没有调用撤销工具。** 若你刚才要的是撤销,那它没有执行 ——' +
                    '这批还在客服手上,请点确认单卡片上的『撤销这批』,或再说一次「撤销刚才那批」。' +
                    '(若只是在回顾之前撤过的批次,可忽略这句。)',
                },
              ],
            },
          ];
        });
      };

      const controller = new AbortController();
      abortRef.current = controller;
      try {
        const token = useAuthStore.getState().accessToken;
        const res = await fetch(new URL('/pac/v1/assistant/chat', env.apiBaseUrl), {
          method: 'POST',
          headers: {
            'Content-Type': 'application/json',
            Accept: 'text/event-stream',
            ...(token ? { Authorization: `Bearer ${token}` } : {}),
          },
          body: JSON.stringify({ messages: apiMessages, model }),
          signal: controller.signal,
        });
        if (!res.ok || !res.body) {
          throw new Error(`HTTP ${res.status}`);
        }

        const reader = res.body.pipeThrough(new TextDecoderStream()).getReader();
        let buffer = '';
        while (true) {
          const { value, done } = await reader.read();
          if (done) break;
          buffer += value;
          let sep: number;
          while ((sep = buffer.indexOf('\n\n')) !== -1) {
            const frame = buffer.slice(0, sep);
            buffer = buffer.slice(sep + 2);
            const line = frame.split('\n').find((l) => l.startsWith('data:'));
            if (!line) continue;
            const raw = line.slice('data:'.length).trim();
            if (!raw) continue;
            try {
              onEvent(JSON.parse(raw) as Record<string, unknown>);
            } catch {
              /* 忽略半截帧 */
            }
          }
        }
        assertRevokeActuallyHappened();
        setStatus('idle');
      } catch (err) {
        if ((err as Error).name !== 'AbortError') {
          appendText(`\n\n⚠️ 连接失败:${(err as Error).message}`);
          setStatus('error');
        } else {
          setStatus('idle');
        }
      } finally {
        abortRef.current = null;
        emitPetEvent({ type: 'ai_thinking_end' });
      }
    },
    [messages, model, status],
  );

  /**
   * ⭐ 确认单点了确认之后,**必须往消息流注入一条文本块**。
   *
   * 因为 `toApiMessage` **只回传文本块**(工具块不回传,模型自行重新决策)——
   * 不注入的话,下一轮主管说「刚才那批改成 5 天」,模型手里完全没有"那批"的痕迹,
   * 会当成新需求重新提议一次。改动很小但极易漏,漏了会被当成模型能力问题。
   */
  const appendAssistantNote = useCallback(
    (text: string, modelText?: string, opts?: { asNewMessage?: boolean }) => {
      const block: Block = { kind: 'text', text, ...(modelText ? { modelText } : {}) };
      setMessages((prev) => {
        if (prev.length === 0) return prev;
        /**
         * 🔴 **确认单落库这一句必须自成一条消息**(2026-08-06 实测)。
         *
         * 默认行为是"贴到最后一条消息的末尾" —— 对「界面回报改单结果」是对的
         * (那是模型那句话的续写)。但**主管点确认**发生在模型发言之外:
         * 贴上去会和上一条的最后一行挤成一段,渲染出来是
         * 「…确认无误就点卡片上的『确认分配』。已确认分配:修复 · 21 条 …这批还没带福利 ——」,
         * 主管扫一眼只当是上一段的尾巴,根本不觉得助手"说了话"(实测反馈:"agent 没有输出引导")。
         * ⇒ 点确认这一路走 `asNewMessage`,让它像助手新说的一句,自己一个气泡。
         */
        if (opts?.asNewMessage) {
          return [...prev, { id: nextId(), role: 'assistant', blocks: [block] }];
        }
        const last = prev[prev.length - 1]!;
        return [...prev.slice(0, -1), { ...last, blocks: [...last.blocks, block] }];
      });
    },
    [],
  );

  /**
   * 「重新排一版」回来的新提案 —— **整块替换**那张确认单。
   *
   * ⚠️ 换的是整张卡:人群没变、人换了。主管在旧卡上做的删/改派对新名单没有意义,
   *   所以卡片自身的 state 也会随 key 变化重置(见下面按 requestId 换 key)。
   * ⛔ 别只替换 items:byAgent / skippedAgents / 几段话术都得跟着换,漏一个就对不上数。
   */
  const replaceSheet = useCallback((requestId: string, next: unknown) => {
    setMessages((prev) =>
      prev.map((m) => ({
        ...m,
        blocks: m.blocks.map((b) =>
          b.kind === 'assignment_sheet' && b.requestId === requestId
            ? { ...b, sheet: next as typeof b.sheet, edits: [] }
            : b,
        ),
      })),
    );
  }, []);

  /** 确认单落库后把卡片切到终态(按钮禁用,显示批次号) */
  const settleSheet = useCallback((requestId: string, assignmentId: string) => {
    setMessages((prev) =>
      prev.map((m) => ({
        ...m,
        blocks: m.blocks.map((b) =>
          b.kind === 'assignment_sheet' && b.requestId === requestId
            ? { ...b, state: 'confirmed' as const, assignmentId }
            : b,
        ),
      })),
    );
  }, []);

  return { messages, status, model, setModel, send, stop, appendAssistantNote, settleSheet, replaceSheet };
}
