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

import { learnSummary, useStyleLearn } from "./useStyleLearn";
import type { JobView } from "@/lib/jobs/job";
import type { UseJobPoll } from "@/lib/jobs/useJobPoll";

// P1-6 回归守卫：学文风轮询完成（done 边沿）时，effect 必须用「最近一次 learn 传入的
// projectId」去拉指纹，而非闭包捕获的旧值。useStyleLearn 用 ref 追踪 projectId 来保证这点。
//
// 该 hook 依赖浏览器/React 渲染，node 环境无法整体跑；这里以最小模型固化 ref 追踪契约：
// 切项目（连续两次 learn）后，done 时读到的应是后者，不会是首次的陈旧值。
describe("useStyleLearn projectId 追踪（P1-6 stale-closure 守卫）", () => {
  // 复刻 hook 内部：learn 同步写 ref，done 时同步读 ref。
  function makeTracker() {
    const projectIdRef: { current: string | null } = { current: null };
    return {
      // learn(pid)：受理时记录最近项目。
      learn(pid: string): void {
        projectIdRef.current = pid;
      },
      // onDone()：轮询完成时取用于拉指纹的项目（应为最新）。
      onDone(): string | null {
        return projectIdRef.current;
      },
    };
  }

  it("done 时用最近一次 learn 的 projectId（切项目后不复用旧值）", () => {
    // Arrange
    const t = makeTracker();

    // Act：先 learn 项目 A，再切到项目 B（模拟用户换项目重学），此时 A 的轮询尚未完成。
    t.learn("project-A");
    t.learn("project-B");
    const used = t.onDone();

    // Assert：done 边沿读到的是 B，而非闭包里陈旧的 A。
    expect(used).toBe("project-B");
  });

  it("未 learn 时为 null（不误拉指纹）", () => {
    const t = makeTracker();
    expect(t.onDone()).toBeNull();
  });
});

// ── 真实 hook 覆盖：renderHook 驱动编排（POST 受理 → 轮询 → done 拉指纹）─────────
const post = vi.fn();
const get = vi.fn();
const toast = vi.fn();
vi.mock("@/lib/api/client", () => ({
  api: {
    POST: (...a: unknown[]) => post(...a),
    GET: (...a: unknown[]) => get(...a),
  },
}));
vi.mock("@/components/Toast", () => ({ useToast: () => toast }));

const STYLE_PATH = "/projects/{project_id}/style";
const JOBS_PATH = "/jobs/{job_id}";

interface GetOpts {
  params: { path: { project_id?: string; job_id?: string } };
}
interface PostOpts {
  params: { path: { project_id: string } };
}

// 微任务 + 0ms 定时器全部冲洗（轮询 tick→dispatch→effect→refetch 链需多跳）。
async function flush(): Promise<void> {
  await act(async () => {
    await vi.advanceTimersByTimeAsync(0);
  });
}

const FINGERPRINT_BODY = {
  dimensions: [{ name: "节奏", value: "快", evidence: ["短句"] }],
  version: 2,
};

describe("useStyleLearn 真实 hook 编排", () => {
  beforeEach(() => {
    vi.useFakeTimers();
    post.mockReset();
    get.mockReset();
    toast.mockReset();
  });
  afterEach(() => {
    vi.clearAllMocks();
    vi.useRealTimers();
  });

  it("初始：未提交则 busy=false、pollStatus=idle、指纹为传入初值", () => {
    const initial = { dimensions: [], version: 1 };
    const { result } = renderHook(() => useStyleLearn(initial));

    expect(result.current.busy).toBe(false);
    expect(result.current.pollStatus).toBe("idle");
    expect(result.current.progress).toBe(0);
    expect(result.current.fingerprint).toBe(initial);
  });

  it("受理失败（通用错误）：返回 false、弹通用 toast、busy 归位", async () => {
    post.mockResolvedValue({ data: null, error: { error: { code: "INTERNAL" } } });

    const { result } = renderHook(() => useStyleLearn(null));
    let ok: boolean | undefined;
    await act(async () => {
      ok = await result.current.learn("p1", ["样本"], "create");
    });

    expect(ok).toBe(false);
    expect(toast).toHaveBeenCalledWith("学文风受理失败，请稍后重试。", "error");
    expect(result.current.busy).toBe(false);
    expect(get).not.toHaveBeenCalled();
  });

  it("受理失败（LLM_UNAVAILABLE）：提示去设置页连一家", async () => {
    post.mockResolvedValue({
      data: null,
      error: { error: { code: "LLM_UNAVAILABLE" } },
    });

    const { result } = renderHook(() => useStyleLearn(null));
    let ok: boolean | undefined;
    await act(async () => {
      ok = await result.current.learn("p1", ["样本"], "create");
    });

    expect(ok).toBe(false);
    expect(toast).toHaveBeenCalledWith(
      "未配置提供商，请先去设置页连一家。",
      "error",
    );
  });

  it("受理成功 → 轮询 done → 拉最新指纹并弹成功 toast", async () => {
    post.mockResolvedValue({ data: { job_id: "job-1" }, error: null });
    get.mockImplementation((path: string, opts: GetOpts) => {
      if (path === JOBS_PATH) {
        return Promise.resolve({
          data: { id: "job-1", status: "done", progress: 100 },
          error: null,
        });
      }
      return Promise.resolve({ data: FINGERPRINT_BODY, error: null });
    });

    const { result } = renderHook(() => useStyleLearn(null));
    let ok: boolean | undefined;
    await act(async () => {
      ok = await result.current.learn("p1", ["样本"], "create");
    });
    await flush();

    expect(ok).toBe(true);
    expect(result.current.pollStatus).toBe("done");
    expect(result.current.fingerprint).toEqual({
      dimensions: [{ name: "节奏", value: "快", evidence: ["短句"] }],
      version: 2,
    });
    expect(toast).toHaveBeenCalledWith("文风指纹已更新。", "success");
  });

  it("done 但拉指纹失败（GET /style error）：指纹不变、仍弹成功 toast", async () => {
    post.mockResolvedValue({ data: { job_id: "job-1" }, error: null });
    get.mockImplementation((path: string) => {
      if (path === JOBS_PATH) {
        return Promise.resolve({
          data: { id: "job-1", status: "done", progress: 100 },
          error: null,
        });
      }
      return Promise.resolve({ data: null, error: { detail: "404" } });
    });

    const initial = { dimensions: [], version: 9 };
    const { result } = renderHook(() => useStyleLearn(initial));
    await act(async () => {
      await result.current.learn("p1", ["样本"], "create");
    });
    await flush();

    expect(result.current.fingerprint).toBe(initial);
    expect(toast).toHaveBeenCalledWith("文风指纹已更新。", "success");
  });

  it("轮询失败（job failed）：弹学文风失败 toast 带原因", async () => {
    post.mockResolvedValue({ data: { job_id: "job-1" }, error: null });
    get.mockImplementation((path: string) => {
      if (path === JOBS_PATH) {
        return Promise.resolve({
          data: { id: "job-1", status: "failed", progress: 40, error: "模型超时" },
          error: null,
        });
      }
      return Promise.resolve({ data: FINGERPRINT_BODY, error: null });
    });

    const { result } = renderHook(() => useStyleLearn(null));
    await act(async () => {
      await result.current.learn("p1", ["样本"], "create");
    });
    await flush();

    expect(result.current.pollStatus).toBe("error");
    expect(toast).toHaveBeenCalledWith("学文风失败：模型超时", "error");
  });

  it("切项目：done 边沿用最近一次 learn 的 projectId 拉指纹（stale-closure 守卫）", async () => {
    const styleProjectIds: string[] = [];
    post.mockImplementation((_path: string, opts: PostOpts) => {
      const pid = opts.params.path.project_id;
      return Promise.resolve({
        data: { job_id: pid === "project-A" ? "job-A" : "job-B" },
        error: null,
      });
    });
    get.mockImplementation((path: string, opts: GetOpts) => {
      if (path === JOBS_PATH) {
        const jobId = opts.params.path.job_id;
        // A 仍在跑（排队 setTimeout 下一拍）；B 直接 done。
        const status = jobId === "job-A" ? "running" : "done";
        return Promise.resolve({
          data: { id: jobId, status, progress: status === "done" ? 100 : 30 },
          error: null,
        });
      }
      styleProjectIds.push(opts.params.path.project_id ?? "?");
      return Promise.resolve({ data: FINGERPRINT_BODY, error: null });
    });

    const { result } = renderHook(() => useStyleLearn(null));
    await act(async () => {
      await result.current.learn("project-A", ["a"], "create");
    });
    await act(async () => {
      await result.current.learn("project-B", ["b"], "update");
    });
    await flush();

    // done 边沿拉指纹用的应是后切的 project-B，而非陈旧的 project-A。
    expect(styleProjectIds).toContain("project-B");
    expect(styleProjectIds).not.toContain("project-A");
    expect(toast).toHaveBeenCalledWith("文风指纹已更新。", "success");
  });
});

// learnSummary 是导出的纯函数：done 且有 job 时回显版本/维度数，否则 null。
describe("learnSummary", () => {
  const baseJob: JobView = {
    id: "j",
    kind: "style_learn",
    status: "done",
    progress: 100,
    result: { version: 3, dims_count: 16 },
    error: null,
  };
  const pollOf = (over: Partial<UseJobPoll>): UseJobPoll =>
    ({
      status: "done",
      progress: 100,
      job: baseJob,
      error: null,
      poll: vi.fn(),
      reset: vi.fn(),
      ...over,
    }) as UseJobPoll;

  it("done 且有 job：回显 version 与 dimsCount", () => {
    expect(learnSummary(pollOf({}))).toEqual({ version: 3, dimsCount: 16 });
  });

  it("非 done：返回 null", () => {
    expect(learnSummary(pollOf({ status: "polling" }))).toBeNull();
  });

  it("done 但无 job：返回 null", () => {
    expect(learnSummary(pollOf({ job: null }))).toBeNull();
  });
});
