'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';

// ⭐ 数据模型 + 纯函数操作已抽到 chat-blocks（草稿状态机是最该有回归的一段逻辑，
//   内联在 hook 里只能端到端点一遍才能验）。这里 re-export 保持下游 import 不变。
import {
  applyIncomingSheet,
  findActiveSheet,
  findToolIdx,
  upsertArtifact,
  type Block,
  type ChatMessage,
  type ChatStatus,
} from './chat-blocks';

export type { Artifact, Block, ChatMessage, ChatStatus, DraftState, ToolStep } from './chat-blocks';

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

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

/**
 * 从 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;
  }
}


/** 把一条消息压平成后端要的 {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 它 + 把此前所有 active 的作废。
            //   逻辑在 chat-blocks.applyIncomingSheet(纯函数,有回归测试)。
            setMessages((prev) =>
              applyIncomingSheet(prev, {
                requestId: String(evt.requestId ?? ''),
                sheet: evt.sheet as AssignmentProposal,
                hostMessageId: assistantId,
              }),
            );
            break;
          case 'assignment_sheet_edit':
            // ⭐ 助手要改**已经呈现**的那张卡:指令挂到最后一张确认单上,由它自己去匹配执行。
            //
            // 🔴 必须**跨消息**找,⛔ 不能用 patch(那只改当前这条助手消息)——
            //   确认单几乎总是在**上一轮**的消息里(主管看着它才提的要求),
            //   只在当前消息里找永远找不到,而且不会报错、界面毫无反应。
            // ⭐ 找的是**唯一那张 `active`**,⛔ 不再是"最后一张"(2026-08-12)——
            //   重出一版之后,最后一张可能正是刚作废的那张,改上去主管一点反应都看不到。
            //   模型手里没有 requestId(服务端铸的,没进它上下文),它说"这批"指的就是当前有效那张。
            setMessages((prev) => {
              const at = findActiveSheet(prev);
              if (!at) return prev;
              const { messageIndex: mi, blockIndex: bi } = at;
              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,
              );
            });
            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 'step_limit':
            /**
             * 🔴 单轮工具步数到顶（服务端 stopWhen 截断）。
             *
             * ⚠️ 必须让主管看见：模型可能停在「我先查一下」之后就没了下文，
             * 而他以为查完了 —— 这跟「说了已撤销但没调工具」是同一类静默失败。
             * ⚠️ 只陈述事实 + 给一个明确的下一步，⛔ 不自动续跑（续不续是他的决定）。
             */
            appendText(
              `\n\n⚠️ **这一轮查到上限了**（${String(evt.limit ?? '')} 步），助手可能还没查完。` +
                `若上面的回答不完整，请说一句「接着查」。`,
            );
            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 };
}
