import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";

import { resolveTextPrompt } from "../internal/prompt-runtime.js";
import {
  buildFixedSearchOnlineCustomId,
  buildFixedSearchOnlineUserPayload,
  judgeCandidatesWithLlm,
  limitCandidatesForLlm,
  resolveFixedSearchLlmFailoverConsecutiveErrors,
  resolveFixedSearchOnlineChunkSize,
  resolveFixedSearchOnlineConcurrency,
  resolveFixedSearchLlmCandidateLimit,
  resolveFixedSearchLlmInputTokenBudget,
  type FixedSearchLlmContext,
  type FixedSearchOnlineClient,
} from "./fixed-search-llm.ts";

import type { ScoredSearchCandidate } from "./fixed-search-runtime.ts";

vi.mock("../internal/prompt-runtime.js", () => ({
  resolveTextPrompt: vi.fn(),
}));

const resolveTextPromptMock = vi.mocked(resolveTextPrompt);
const originalEnv = { ...process.env };

beforeEach(() => {
  resolveTextPromptMock.mockResolvedValue({
    promptKey: "automation.fixed_search.candidate_match.system",
    contentFormat: "text",
    payload: { content: "store fixed search system" },
    text: "store fixed search system",
    variables: [],
    metadata: {},
    source: "store",
  } as Awaited<ReturnType<typeof resolveTextPrompt>>);
});

afterEach(() => {
  vi.clearAllMocks();
  vi.unstubAllGlobals();
  process.env = { ...originalEnv };
});

function buildCandidate(index: number, score = 100 - index): ScoredSearchCandidate {
  return {
    input: "电磁阀 4V-310-1O",
    "title-text": `气动电磁阀 4V310 候选商品 ${index}`,
    url: `https://detail.1688.com/offer/${index}.html`,
    score,
    search_round: 1,
    round_label: "主搜索词",
    source_product: {
      title: `气动电磁阀 4V310 候选商品 ${index}`,
      offer_url: `https://detail.1688.com/offer/${index}.html`,
      merchant_name: `商家 ${index}`,
      merchant_url: "",
      image_url: "",
      wangwang_url: "",
      contact_page_url: "",
      offer_screenshot_path: "",
    },
  };
}

function buildOnlineClient(
  handler: (request: unknown, callIndex: number, options?: { signal?: AbortSignal }) => unknown | Promise<unknown>,
  provider: FixedSearchOnlineClient["provider"] = "mimo",
  model = provider === "mimo" ? "mimo-v2.5" : "deepseek-v4-pro",
): FixedSearchOnlineClient {
  let callIndex = 0;
  return {
    provider,
    model,
    createChatCompletion: vi.fn(async (request: unknown, options?: { signal?: AbortSignal }) => {
      const currentCall = callIndex;
      callIndex += 1;
      return handler(request, currentCall, options);
    }),
  };
}

function completion(results: unknown[]) {
  return {
    choices: [
      {
        finish_reason: "stop",
        message: {
          content: JSON.stringify({ results }),
        },
      },
    ],
  };
}

const context: FixedSearchLlmContext = {
  primary_search_term: "电磁阀 4V-310-1O",
  standardized_name: "电磁阀",
  brand: "",
  standardized_spec: "4V-310-1O",
  category: "气动控制",
  ai_reasoning: "原始需求为电磁阀，规格型号为4V-310-1O。4V系列是气动行业常用的电磁换向阀型号。",
  product_type: "独立零部件",
};

describe("limitCandidatesForLlm", () => {
  it("keeps only the configured leading candidates", () => {
    const candidates = Array.from({ length: 8 }, (_, index) => buildCandidate(index + 1));

    expect(limitCandidatesForLlm(candidates, 5).map((candidate) => candidate.url)).toEqual(
      candidates.slice(0, 5).map((candidate) => candidate.url),
    );
  });

  it("also respects the estimated input token budget", () => {
    const candidates = Array.from({ length: 8 }, (_, index) => buildCandidate(index + 1));

    expect(limitCandidatesForLlm(candidates, 500, 80).length).toBeLessThan(8);
  });
});

describe("fixed search online defaults", () => {
  it("uses MiMo-sized chunks by default and accepts positive overrides", () => {
    expect(resolveFixedSearchOnlineChunkSize("")).toBe(25);
    expect(resolveFixedSearchOnlineChunkSize("50")).toBe(50);
    expect(resolveFixedSearchOnlineConcurrency("")).toBe(4);
    expect(resolveFixedSearchOnlineConcurrency("2")).toBe(2);
  });

  it("keeps candidate limit defaults", () => {
    expect(resolveFixedSearchLlmCandidateLimit("")).toBe(500);
    expect(resolveFixedSearchLlmInputTokenBudget("")).toBe(100_000);
  });

  it("fails over after two consecutive failed chunks by default", () => {
    expect(resolveFixedSearchLlmFailoverConsecutiveErrors("")).toBe(2);
    expect(resolveFixedSearchLlmFailoverConsecutiveErrors("3")).toBe(3);
  });
});

describe("fixed search online request", () => {
  it("puts scoring context and multiple candidates in one JSON payload", () => {
    const candidates = [buildCandidate(1), buildCandidate(2)].map((candidate, index) => ({
      customId: buildFixedSearchOnlineCustomId({ taskId: "task-1", materialId: "material-1", candidateIndex: index }),
      candidate,
      index,
    }));

    const payload = buildFixedSearchOnlineUserPayload(context, candidates);

    expect(payload.context.standardized_spec).toBe("4V-310-1O");
    expect(payload.candidates).toHaveLength(2);
    expect(payload.candidates[0]).toMatchObject({
      index: 0,
      input: "电磁阀 4V-310-1O",
      title_text: "气动电磁阀 4V310 候选商品 1",
      score: 99,
    });
    expect(payload.output_contract.results[0]).toMatchObject({
      index: 0,
      custom_id: "same as input",
      is_match: true,
    });
  });
});

describe("judgeCandidatesWithLlm online chunks", () => {
  it("prefers MiMo env credentials when the legacy fixed-search provider is still GLM", async () => {
    process.env.FIXED_SEARCH_LLM_PROVIDER = "glm";
    process.env.FIXED_SEARCH_LLM_MODEL = "glm-4-plus";
    process.env.MIMO_LLM_BASE_URL = "https://api.xiaomimimo.com/v1";
    process.env.MIMO_LLM_API_KEY = "mimo-key";
    const fetchMock = vi.fn(async (_url: string, init?: RequestInit) => {
      const request = JSON.parse(String(init?.body ?? "{}"));
      const payload = JSON.parse(request.messages.find((message: { role: string }) => message.role === "user")?.content ?? "{}");
      return new Response(JSON.stringify(completion(payload.candidates.map((candidate: { index: number; custom_id: string }) => ({
        index: candidate.index,
        custom_id: candidate.custom_id,
        is_match: true,
        final_score: 91,
        confidence: 0.86,
        reason: "MiMo 匹配",
      })))), {
        status: 200,
        headers: { "Content-Type": "application/json" },
      });
    });
    vi.stubGlobal("fetch", fetchMock);

    const results = await judgeCandidatesWithLlm({
      context,
      taskId: "task-1",
      materialId: "material-1",
      candidates: [buildCandidate(1, 44)],
    });

    expect(fetchMock).toHaveBeenCalledWith(
      "https://api.xiaomimimo.com/v1/chat/completions",
      expect.objectContaining({
        headers: expect.objectContaining({
          "api-key": "mimo-key",
          "Content-Type": "application/json",
        }),
      }),
    );
    expect(results[0]).toMatchObject({
      llm_provider: "mimo",
      llm_model: "mimo-v2.5",
      score: 91,
      is_match: true,
    });
  });

  it("resolves the fixed search prompt for the online provider and sends JSON chunks", async () => {
    const client = buildOnlineClient((request) => {
      const body = request as { messages: Array<{ role: string; content: string }>; response_format?: { type?: string } };
      const payload = JSON.parse(body.messages.find((message) => message.role === "user")?.content ?? "{}");
      return completion(payload.candidates.map((candidate: { index: number; custom_id: string }) => ({
        index: candidate.index,
        custom_id: candidate.custom_id,
        is_match: true,
        final_score: 88,
        confidence: 0.82,
        reason: "型号匹配",
      })));
    });

    const results = await judgeCandidatesWithLlm({
      context,
      taskId: "task-1",
      materialId: "material-1",
      candidates: [buildCandidate(1, 44), buildCandidate(2, 0)],
      onlineClient: client,
      onlineChunkSize: 25,
      onlineConcurrency: 4,
    });

    expect(resolveTextPromptMock).toHaveBeenCalledWith(
      "automation.fixed_search.candidate_match.system",
      { provider: "mimo", model: "mimo-v2.5" },
    );
    expect(client.createChatCompletion).toHaveBeenCalledTimes(1);
    const request = vi.mocked(client.createChatCompletion).mock.calls[0]?.[0] as { messages: Array<{ role: string; content: string }>; response_format?: { type?: string } };
    expect(request.response_format).toEqual({ type: "json_object" });
    expect(request.messages[0]).toEqual({
      role: "system",
      content: expect.stringContaining("store fixed search system"),
    });
    expect(JSON.parse(request.messages[1]?.content ?? "{}").candidates).toHaveLength(1);
    expect(results.map((result) => result.judge_status)).toEqual(["judged", "skipped_zero_score"]);
    expect(results[0]).toMatchObject({
      base_score: 44,
      score: 88,
      confidence: 0.82,
      is_match: true,
      llm_provider: "mimo",
      llm_model: "mimo-v2.5",
    });
  });

  it("restores output order by custom_id across chunks", async () => {
    const client = buildOnlineClient((request) => {
      const body = request as { messages: Array<{ role: string; content: string }> };
      const payload = JSON.parse(body.messages.find((message) => message.role === "user")?.content ?? "{}");
      return completion([...payload.candidates].reverse().map((candidate: { index: number; custom_id: string }) => ({
        index: candidate.index,
        custom_id: candidate.custom_id,
        is_match: candidate.index !== 1,
        final_score: candidate.index === 1 ? 20 : 90,
        confidence: 0.7,
        reason: candidate.index === 1 ? "不匹配" : "匹配",
      })));
    });

    const results = await judgeCandidatesWithLlm({
      context,
      taskId: "task-1",
      materialId: "material-1",
      candidates: [buildCandidate(1, 88), buildCandidate(2, 87), buildCandidate(3, 86)],
      onlineClient: client,
      onlineChunkSize: 2,
      onlineConcurrency: 2,
    });

    expect(client.createChatCompletion).toHaveBeenCalledTimes(2);
    expect(results.map((result) => result.is_match)).toEqual([true, false, true]);
    expect(results.map((result) => result.score)).toEqual([90, 20, 90]);
  });

  it("parses three-way match buckets and keeps legacy output compatible", async () => {
    const client = buildOnlineClient((request) => {
      const body = request as { messages: Array<{ role: string; content: string }> };
      const payload = JSON.parse(body.messages.find((message) => message.role === "user")?.content ?? "{}");
      return completion([
        {
          index: payload.candidates[0].index,
          custom_id: payload.candidates[0].custom_id,
          is_match: false,
          match_bucket: "possible",
          final_score: 68,
          confidence: 0.66,
          reason: "同品牌同类目但型号不精确",
        },
        {
          index: payload.candidates[1].index,
          custom_id: payload.candidates[1].custom_id,
          is_match: true,
          final_score: 91,
          confidence: 0.84,
          reason: "旧格式匹配",
        },
      ]);
    });

    const results = await judgeCandidatesWithLlm({
      context,
      taskId: "task-1",
      materialId: "material-1",
      candidates: [buildCandidate(1, 88), buildCandidate(2, 87)],
      onlineClient: client,
      onlineChunkSize: 2,
      onlineConcurrency: 1,
    });

    expect(results.map((result) => result.match_bucket)).toEqual(["possible", "matched"]);
    expect(results.map((result) => result.is_match)).toEqual([false, true]);
  });

  it("marks missing or invalid chunk results as llm_error without failing other chunks", async () => {
    const client = buildOnlineClient((request, callIndex) => {
      if (callIndex === 1) {
        throw new Error("rate limit");
      }
      const body = request as { messages: Array<{ role: string; content: string }> };
      const payload = JSON.parse(body.messages.find((message) => message.role === "user")?.content ?? "{}");
      const first = payload.candidates[0];
      return completion([
        {
          index: first.index,
          custom_id: first.custom_id,
          is_match: true,
          final_score: 76,
          confidence: 0.72,
          reason: "匹配",
        },
      ]);
    });

    const results = await judgeCandidatesWithLlm({
      context,
      taskId: "task-1",
      materialId: "material-1",
      candidates: [buildCandidate(1, 88), buildCandidate(2, 87), buildCandidate(3, 86), buildCandidate(4, 85)],
      onlineClient: client,
      onlineChunkSize: 2,
      onlineConcurrency: 2,
    });

    expect(results.map((result) => result.judge_status)).toEqual([
      "judged",
      "llm_error",
      "llm_error",
      "llm_error",
    ]);
    expect(results[1]?.reason).toContain("在线 LLM 未返回该候选结果");
    expect(results[2]?.reason).toContain("rate limit");
  });

  it("fails fast to DeepSeek after two consecutive MiMo chunk failures", async () => {
    const mimoClient = buildOnlineClient((_request, callIndex) => {
      throw new Error(callIndex === 0 ? "timeout" : "empty response");
    });
    const deepseekClient = buildOnlineClient((request) => {
      const body = request as { messages: Array<{ role: string; content: string }> };
      const payload = JSON.parse(body.messages.find((message) => message.role === "user")?.content ?? "{}");
      return completion(payload.candidates.map((candidate: { index: number; custom_id: string }) => ({
        index: candidate.index,
        custom_id: candidate.custom_id,
        is_match: true,
        final_score: 92,
        confidence: 0.88,
        reason: "DeepSeek 接管匹配",
      })));
    }, "deepseek");
    const progressEvents: Array<{ event?: string; message?: string; failoverThreshold?: number; failureReason?: string }> = [];

    const results = await judgeCandidatesWithLlm({
      context,
      taskId: "task-1",
      materialId: "material-1",
      candidates: Array.from({ length: 6 }, (_, index) => buildCandidate(index + 1, 90 - index)),
      onlineClient: mimoClient,
      fallbackOnlineClient: deepseekClient,
      onlineChunkSize: 1,
      onlineConcurrency: 1,
      failoverConsecutiveErrors: 2,
      onProgress: (progress) => {
        progressEvents.push(progress);
      },
    });

    expect(mimoClient.createChatCompletion).toHaveBeenCalledTimes(2);
    expect(deepseekClient.createChatCompletion).toHaveBeenCalledTimes(1);
    expect(results).toHaveLength(6);
    expect(results.every((result) => result.judge_status === "judged")).toBe(true);
    expect(results.every((result) => result.llm_provider === "deepseek")).toBe(true);
    expect(results.map((result) => result.score)).toEqual([92, 92, 92, 92, 92, 92]);
    expect(progressEvents).toEqual(expect.arrayContaining([
      expect.objectContaining({
        event: "provider_failover",
        failoverThreshold: 2,
        message: expect.stringContaining("已切换 DeepSeek"),
        failureReason: "empty response",
      }),
    ]));
  });

  it("does not trigger global failover for a single intermittent MiMo chunk failure", async () => {
    const mimoClient = buildOnlineClient((request, callIndex) => {
      if (callIndex === 0) {
        throw new Error("temporary timeout");
      }
      const body = request as { messages: Array<{ role: string; content: string }> };
      const payload = JSON.parse(body.messages.find((message) => message.role === "user")?.content ?? "{}");
      return completion(payload.candidates.map((candidate: { index: number; custom_id: string }) => ({
        index: candidate.index,
        custom_id: candidate.custom_id,
        is_match: true,
        final_score: 81,
        confidence: 0.78,
        reason: "MiMo 恢复匹配",
      })));
    });
    const deepseekClient = buildOnlineClient((request) => {
      const body = request as { messages: Array<{ role: string; content: string }> };
      const payload = JSON.parse(body.messages.find((message) => message.role === "user")?.content ?? "{}");
      return completion(payload.candidates.map((candidate: { index: number; custom_id: string }) => ({
        index: candidate.index,
        custom_id: candidate.custom_id,
        is_match: true,
        final_score: 90,
        confidence: 0.86,
        reason: "DeepSeek 补齐失败候选",
      })));
    }, "deepseek");
    const progressEvents: Array<{ event?: string }> = [];

    const results = await judgeCandidatesWithLlm({
      context,
      taskId: "task-1",
      materialId: "material-1",
      candidates: Array.from({ length: 4 }, (_, index) => buildCandidate(index + 1, 90 - index)),
      onlineClient: mimoClient,
      fallbackOnlineClient: deepseekClient,
      onlineChunkSize: 1,
      onlineConcurrency: 1,
      failoverConsecutiveErrors: 2,
      onProgress: (progress) => {
        progressEvents.push(progress);
      },
    });

    expect(mimoClient.createChatCompletion).toHaveBeenCalledTimes(4);
    expect(deepseekClient.createChatCompletion).toHaveBeenCalledTimes(1);
    expect(progressEvents.some((event) => event.event === "provider_failover")).toBe(false);
    expect(results.map((result) => result.llm_provider)).toEqual(["deepseek", "mimo", "mimo", "mimo"]);
    expect(results.map((result) => result.score)).toEqual([90, 81, 81, 81]);
  });

  it("does not call DeepSeek when all MiMo chunks succeed", async () => {
    const mimoClient = buildOnlineClient((request) => {
      const body = request as { messages: Array<{ role: string; content: string }> };
      const payload = JSON.parse(body.messages.find((message) => message.role === "user")?.content ?? "{}");
      return completion(payload.candidates.map((candidate: { index: number; custom_id: string }) => ({
        index: candidate.index,
        custom_id: candidate.custom_id,
        is_match: true,
        final_score: 84,
        confidence: 0.8,
        reason: "MiMo 匹配",
      })));
    });
    const deepseekClient = buildOnlineClient(() => completion([]), "deepseek");

    const results = await judgeCandidatesWithLlm({
      context,
      taskId: "task-1",
      materialId: "material-1",
      candidates: [buildCandidate(1, 88), buildCandidate(2, 87)],
      onlineClient: mimoClient,
      fallbackOnlineClient: deepseekClient,
      onlineChunkSize: 1,
      onlineConcurrency: 1,
    });

    expect(mimoClient.createChatCompletion).toHaveBeenCalledTimes(2);
    expect(deepseekClient.createChatCompletion).not.toHaveBeenCalled();
    expect(results.map((result) => result.llm_provider)).toEqual(["mimo", "mimo"]);
  });
});
