// @vitest-environment jsdom
import { act, renderHook } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";

import { useReviewStream } from "./useReviewStream";

// fetch（审稿 SSE 流）是 hook 的外部副作用边界，单测一律 stub 全局 fetch。
const fetchMock = vi.fn();

function sseStream(chunks: string[]): ReadableStream<Uint8Array> {
  const enc = new TextEncoder();
  return new ReadableStream({
    start(c) {
      for (const ch of chunks) c.enqueue(enc.encode(ch));
      c.close();
    },
  });
}

function sseResponse(chunks: string[]): Response {
  return new Response(sseStream(chunks), {
    status: 200,
    headers: { "Content-Type": "text/event-stream" },
  });
}

describe("useReviewStream", () => {
  beforeEach(() => {
    fetchMock.mockReset();
    vi.stubGlobal("fetch", fetchMock);
  });
  afterEach(() => {
    vi.unstubAllGlobals();
    vi.clearAllMocks();
  });

  it("初始为 idle、各结果集为空、未在审稿", () => {
    const { result } = renderHook(() => useReviewStream());
    expect(result.current.state.phase).toBe("idle");
    expect(result.current.state.conflicts).toEqual([]);
    expect(result.current.state.sections).toEqual([]);
    expect(result.current.isReviewing).toBe(false);
  });

  it("流式累积四类结果：section/conflict/foreshadow/pace/style 折叠进状态", async () => {
    fetchMock.mockResolvedValue(
      sseResponse([
        'event:section\ndata:{"name":"一致性","status":"started"}\n\n',
        'event:section\ndata:{"name":"一致性","status":"done"}\n\n',
        'event:conflict\ndata:{"type":"性格漂移","where":"第3段","suggestion":"改回冷静","refs":["c1"]}\n\n',
        'event:foreshadow\ndata:{"kind":"planted","title":"神秘信物","code":"F1"}\n\n',
        'event:pace\ndata:{"water":[{"where":"开头","reason":"铺垫过长"}],"hook":true,"beat_map":[1,2,3]}\n\n',
        'event:style\ndata:{"score":0.82,"segments":[{"idx":0,"text":"原文","score":0.5,"label":"偏离"}]}\n\n',
      ]),
    );

    const { result } = renderHook(() => useReviewStream());
    await act(async () => {
      await result.current.start("p1", 1, "草稿正文");
    });

    const s = result.current.state;
    // section 按 name upsert：同名只留最后状态。
    expect(s.sections).toEqual([{ name: "一致性", status: "done" }]);
    expect(s.conflicts).toHaveLength(1);
    expect(s.conflicts[0]).toMatchObject({ type: "性格漂移", where: "第3段" });
    expect(s.foreshadow).toHaveLength(1);
    expect(s.foreshadow[0]).toMatchObject({ kind: "planted", title: "神秘信物" });
    expect(s.pace).toMatchObject({ hook: true, beat_map: [1, 2, 3] });
    expect(s.style?.score).toBe(0.82);
    expect(s.style?.segments[0]).toMatchObject({ idx: 0, text: "原文" });
  });

  it("以 JSON body POST 当前草稿正文", async () => {
    fetchMock.mockResolvedValue(sseResponse(['event:done\ndata:{"length":0}\n\n']));

    const { result } = renderHook(() => useReviewStream());
    await act(async () => {
      await result.current.start("p1", 7, "需要重审的草稿");
    });

    const [url, init] = fetchMock.mock.calls[0]!;
    expect(String(url)).toContain("/projects/p1/chapters/7/review");
    expect(init.method).toBe("POST");
    expect(JSON.parse(init.body as string)).toEqual({ draft: "需要重审的草稿" });
  });

  it("收到 done 帧后 phase 走到 done", async () => {
    fetchMock.mockResolvedValue(
      sseResponse([
        'event:conflict\ndata:{"type":"设定违例","where":"第1段","suggestion":"补设定"}\n\n',
        'event:done\ndata:{"length":120}\n\n',
      ]),
    );

    const { result } = renderHook(() => useReviewStream());
    await act(async () => {
      await result.current.start("p1", 1, "draft");
    });

    expect(result.current.state.phase).toBe("done");
    expect(result.current.state.conflicts).toHaveLength(1);
  });

  it("收到 error 帧：phase=error 且带错误码与文案", async () => {
    fetchMock.mockResolvedValue(
      sseResponse(['event:error\ndata:{"code":"TIMEOUT","message":"审稿超时"}\n\n']),
    );

    const { result } = renderHook(() => useReviewStream());
    await act(async () => {
      await result.current.start("p1", 1, "draft");
    });

    expect(result.current.state.phase).toBe("error");
    expect(result.current.state.error).toMatchObject({
      code: "TIMEOUT",
      message: "审稿超时",
    });
  });

  it("流前错误（!res.ok）：解析 JSON 信封提取错误码与文案", async () => {
    fetchMock.mockResolvedValue(
      new Response(
        JSON.stringify({ error: { code: "LLM_UNAVAILABLE", message: "无可用凭据" } }),
        { status: 503 },
      ),
    );

    const { result } = renderHook(() => useReviewStream());
    await act(async () => {
      await result.current.start("p1", 1, "draft");
    });

    expect(result.current.state.phase).toBe("error");
    expect(result.current.state.error).toMatchObject({
      code: "LLM_UNAVAILABLE",
      message: "无可用凭据",
    });
  });

  it("流前错误且非 JSON 信封：沿用默认 REVIEW_FAILED 文案", async () => {
    fetchMock.mockResolvedValue(new Response("oops", { status: 500 }));

    const { result } = renderHook(() => useReviewStream());
    await act(async () => {
      await result.current.start("p1", 1, "draft");
    });

    expect(result.current.state.phase).toBe("error");
    expect(result.current.state.error?.code).toBe("REVIEW_FAILED");
    expect(result.current.state.error?.message).toContain("500");
  });

  it("网络抛异常（非 Abort）：phase=error 且 code=NETWORK", async () => {
    fetchMock.mockRejectedValue(new Error("connection reset"));

    const { result } = renderHook(() => useReviewStream());
    await act(async () => {
      await result.current.start("p1", 1, "draft");
    });

    expect(result.current.state.phase).toBe("error");
    expect(result.current.state.error).toMatchObject({
      code: "NETWORK",
      message: "connection reset",
    });
  });

  it("非 Error 抛出：回退为未知网络错误文案", async () => {
    fetchMock.mockRejectedValue(42);

    const { result } = renderHook(() => useReviewStream());
    await act(async () => {
      await result.current.start("p1", 1, "draft");
    });

    expect(result.current.state.error?.message).toBe("未知网络错误");
  });

  it("AbortError 被吞掉：不进入 error，phase 维持 reviewing", async () => {
    fetchMock.mockRejectedValue(new DOMException("aborted", "AbortError"));

    const { result } = renderHook(() => useReviewStream());
    await act(async () => {
      await result.current.start("p1", 1, "draft");
    });

    expect(result.current.state.phase).toBe("reviewing");
    expect(result.current.state.error).toBeNull();
  });

  it("stop() 主动停止：abort 连接并将 phase 置 aborted", async () => {
    const hangingBody = new ReadableStream<Uint8Array>({});
    fetchMock.mockResolvedValue(new Response(hangingBody, { status: 200 }));

    const { result } = renderHook(() => useReviewStream());
    act(() => {
      void result.current.start("p1", 1, "draft");
    });
    await act(async () => {
      result.current.stop();
    });

    expect(result.current.state.phase).toBe("aborted");
    expect(result.current.isReviewing).toBe(false);
  });

  it("seed() 用历史留痕种入状态（无需重审即可查看）", () => {
    const { result } = renderHook(() => useReviewStream());
    const seedData = {
      conflicts: [
        {
          type: "时间线倒错",
          where: "第2段",
          refs: [],
          suggestion: "对齐时间",
          original: null,
          replacement: null,
        },
      ],
      foreshadow: [{ kind: "resolved" as const, title: "旧伏笔" }],
      pace: { water: [], hook: false, beat_map: [1] },
      style: { score: 0.9, segments: [] },
      characterization: {
        issues: [
          {
            character: "沈砚",
            aspect: "动机一致性",
            where: "第3段",
            quote: "他忽然转身。",
            diagnosis: "动机断裂",
            suggestion: "扣回护妹动机",
            confidence: 0.8,
          },
        ],
      },
    };

    act(() => result.current.seed(seedData));

    expect(result.current.state.conflicts).toEqual(seedData.conflicts);
    expect(result.current.state.foreshadow).toEqual(seedData.foreshadow);
    expect(result.current.state.pace).toEqual(seedData.pace);
    expect(result.current.state.style).toEqual(seedData.style);
    expect(result.current.state.characterization).toEqual(
      seedData.characterization,
    );
    expect(result.current.state.phase).toBe("idle");
  });
});
