import { mkdir, writeFile } from "node:fs/promises";
import { createHash } from "node:crypto";
import path from "node:path";

import {
  resolveLlmRuntimeConfig,
  type ResolvedLlmRuntimeConfig,
} from "../core/llm-client.ts";
import { resolveTextPrompt } from "../internal/prompt-runtime.js";

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

export type FixedSearchLlmContext = {
  primary_search_term: string;
  standardized_name: string;
  brand: string;
  standardized_spec: string;
  category: string;
  ai_reasoning: string;
  product_type: string;
};

export type FixedSearchJudgeStatus =
  | "judged"
  | "skipped_zero_score"
  | "skipped_candidate_limit"
  | "llm_error";

export type FixedSearchMatchBucket = "matched" | "possible" | "not_match";

export type LlmMatchedSearchCandidate = ScoredSearchCandidate & {
  base_score: number;
  confidence: number | null;
  is_match: boolean | null;
  match_bucket: FixedSearchMatchBucket | null;
  reason: string;
  judge_status: FixedSearchJudgeStatus;
  llm_provider?: string;
  llm_model?: string;
  llm_chunk_id?: string;
  llm_custom_id?: string;
};

export type FixedSearchOnlineClient = {
  provider: "mimo" | "deepseek";
  model: string;
  createChatCompletion: (request: FixedSearchOnlineChatRequest, options?: FixedSearchOnlineRequestOptions) => Promise<unknown>;
};

type FixedSearchOnlineRequestOptions = {
  signal?: AbortSignal;
};

type FixedSearchOnlineChatRequest = {
  model: string;
  messages: Array<{ role: "system" | "user"; content: string }>;
  temperature: number;
  max_tokens: number;
  response_format: { type: "json_object" };
};

type FixedSearchOnlineCandidateInput = {
  index: number;
  custom_id: string;
  input: string;
  title_text: string;
  url: string;
  score: number;
};

type FixedSearchOnlineUserPayload = {
  context: {
    primary_search_term: string;
    standardized_name: string;
    brand: string;
    standardized_spec: string;
    category: string;
    product_type: string;
    ai_reasoning: string;
  };
  candidates: FixedSearchOnlineCandidateInput[];
  output_contract: {
    results: Array<{
      index: number;
      custom_id: string;
      is_match: boolean;
      match_bucket: FixedSearchMatchBucket;
      final_score: number;
      confidence: number;
      reason: string;
    }>;
  };
};

type CandidateWithOnlineId = {
  index: number;
  customId: string;
  candidate: ScoredSearchCandidate;
};

type ParsedOnlineResult = {
  index: number;
  custom_id: string;
  is_match: boolean;
  match_bucket?: unknown;
  final_score?: unknown;
  confidence?: unknown;
  reason?: unknown;
};

type FixedSearchOnlineProviderConfig = ResolvedLlmRuntimeConfig & {
  provider: "mimo" | "deepseek";
};

const DEFAULT_LLM_CANDIDATE_LIMIT = 500;
const DEFAULT_LLM_INPUT_TOKEN_BUDGET = 100_000;
const DEFAULT_ONLINE_CHUNK_SIZE = 25;
const DEFAULT_ONLINE_CONCURRENCY = 4;
const DEFAULT_ONLINE_MAX_TOKENS = 8192;
const DEFAULT_ONLINE_TIMEOUT_MS = 90_000;
const DEFAULT_FALLBACK_CHUNK_SIZE = 10;
const DEFAULT_FALLBACK_CONCURRENCY = 2;
const DEFAULT_FAILOVER_CONSECUTIVE_ERRORS = 2;
const DEFAULT_MIMO_MODEL = "mimo-v2.5";
const DEFAULT_DEEPSEEK_MODEL = "deepseek-v4-pro";
const FIXED_SEARCH_CANDIDATE_MATCH_PROMPT_KEY = "automation.fixed_search.candidate_match.system";
const ALLOWED_FIXED_SEARCH_PROVIDERS = new Set(["mimo", "deepseek"]);

function normalizeSpace(value: unknown) {
  return String(value ?? "").replace(/\s+/g, " ").trim();
}

function normalizeProvider(value: unknown): "mimo" | "deepseek" | "" {
  const normalized = normalizeSpace(value).toLowerCase();
  return normalized === "mimo" || normalized === "deepseek" ? normalized : "";
}

function parsePositiveInteger(value: unknown, fallback: number) {
  const parsed = Number.parseInt(String(value ?? ""), 10);
  return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
}

function formatErrorMessage(error: unknown) {
  if (error instanceof Error) {
    return error.message;
  }
  return String(error);
}

function tryParseJsonObject(raw: string) {
  const cleaned = normalizeSpace(raw)
    .replace(/^```json\s*/i, "")
    .replace(/^```/i, "")
    .replace(/```$/i, "")
    .trim();
  if (!cleaned) {
    return null;
  }

  const match = cleaned.match(/\{[\s\S]*\}/);
  if (!match) {
    return null;
  }

  try {
    return JSON.parse(match[0]) as Record<string, unknown>;
  } catch {
    return null;
  }
}

function candidateInput(candidate: ScoredSearchCandidate) {
  return {
    input: candidate.input,
    title_text: candidate["title-text"],
    url: candidate.url,
    score: candidate.score,
  };
}

function normalizeContextValue(value: unknown) {
  return normalizeSpace(value) || "无";
}

function resolveFixedSearchPromptScope(client: Pick<FixedSearchOnlineClient, "provider" | "model">) {
  return {
    provider: client.provider,
    model: client.model,
  };
}

async function resolveFixedSearchCandidateMatchSystemPrompt(client: Pick<FixedSearchOnlineClient, "provider" | "model">) {
  const prompt = await resolveTextPrompt(
    FIXED_SEARCH_CANDIDATE_MATCH_PROMPT_KEY,
    resolveFixedSearchPromptScope(client),
  );
  return prompt.text;
}

export function buildFixedSearchOnlineUserPayload(
  context: FixedSearchLlmContext,
  candidates: CandidateWithOnlineId[],
): FixedSearchOnlineUserPayload {
  return {
    context: {
      primary_search_term: normalizeContextValue(context.primary_search_term),
      standardized_name: normalizeContextValue(context.standardized_name),
      brand: normalizeContextValue(context.brand),
      standardized_spec: normalizeContextValue(context.standardized_spec),
      category: normalizeContextValue(context.category),
      product_type: normalizeContextValue(context.product_type),
      ai_reasoning: normalizeContextValue(context.ai_reasoning),
    },
    candidates: candidates.map((entry) => {
      const input = candidateInput(entry.candidate);
      return {
        index: entry.index,
        custom_id: entry.customId,
        input: input.input,
        title_text: input.title_text,
        url: input.url,
        score: input.score,
      };
    }),
    output_contract: {
      results: [
        {
          index: 0,
          custom_id: "same as input",
          is_match: true,
          match_bucket: "matched",
          final_score: 0,
          confidence: 0,
          reason: "中文简短原因",
        },
      ],
    },
  };
}

export function buildFixedSearchOnlineCustomId(params: {
  taskId: string;
  materialId: string;
  candidateIndex: number;
}) {
  const hashToken = (value: unknown, fallback: string) => {
    const normalized = normalizeSpace(value) || fallback;
    return createHash("sha1")
      .update(normalized)
      .digest("base64url")
      .slice(0, 12);
  };

  return [
    "fo",
    hashToken(params.taskId, "no_task"),
    hashToken(params.materialId, "no_material"),
    String(params.candidateIndex),
  ].join("_");
}

export function resolveFixedSearchLlmCandidateLimit(value: unknown = process.env.FIXED_SEARCH_LLM_CANDIDATE_LIMIT) {
  return parsePositiveInteger(value, DEFAULT_LLM_CANDIDATE_LIMIT);
}

export function resolveFixedSearchLlmInputTokenBudget(value: unknown = process.env.FIXED_SEARCH_LLM_INPUT_TOKEN_BUDGET) {
  return parsePositiveInteger(value, DEFAULT_LLM_INPUT_TOKEN_BUDGET);
}

export function resolveFixedSearchOnlineChunkSize(value: unknown = process.env.FIXED_SEARCH_LLM_ONLINE_CHUNK_SIZE) {
  return parsePositiveInteger(value, DEFAULT_ONLINE_CHUNK_SIZE);
}

export function resolveFixedSearchOnlineConcurrency(value: unknown = process.env.FIXED_SEARCH_LLM_ONLINE_CONCURRENCY) {
  return parsePositiveInteger(value, DEFAULT_ONLINE_CONCURRENCY);
}

function resolveFallbackChunkSize(value: unknown = process.env.FIXED_SEARCH_LLM_FALLBACK_CHUNK_SIZE) {
  return parsePositiveInteger(value, DEFAULT_FALLBACK_CHUNK_SIZE);
}

function resolveFallbackConcurrency(value: unknown = process.env.FIXED_SEARCH_LLM_FALLBACK_CONCURRENCY) {
  return parsePositiveInteger(value, DEFAULT_FALLBACK_CONCURRENCY);
}

function resolveOnlineTimeoutMs(value: unknown = process.env.FIXED_SEARCH_LLM_ONLINE_TIMEOUT_MS) {
  return parsePositiveInteger(value, DEFAULT_ONLINE_TIMEOUT_MS);
}

export function resolveFixedSearchLlmFailoverConsecutiveErrors(
  value: unknown = process.env.FIXED_SEARCH_LLM_FAILOVER_CONSECUTIVE_ERRORS,
) {
  return parsePositiveInteger(value, DEFAULT_FAILOVER_CONSECUTIVE_ERRORS);
}

function estimateInputTokens(value: unknown) {
  return Math.ceil(JSON.stringify(value ?? "").length / 2);
}

function estimateCandidateInputTokens(candidate: ScoredSearchCandidate) {
  return estimateInputTokens(candidateInput(candidate));
}

export function limitCandidatesForLlm(
  candidates: ScoredSearchCandidate[],
  limit = resolveFixedSearchLlmCandidateLimit(),
  inputTokenBudget = resolveFixedSearchLlmInputTokenBudget(),
) {
  if (!Number.isInteger(limit) || limit <= 0) {
    return [];
  }

  const output: ScoredSearchCandidate[] = [];
  let estimatedTokens = 0;
  for (const candidate of candidates) {
    if (output.length >= limit) {
      break;
    }

    const nextTokens = estimateCandidateInputTokens(candidate);
    if (output.length > 0 && estimatedTokens + nextTokens > inputTokenBudget) {
      break;
    }

    output.push(candidate);
    estimatedTokens += nextTokens;
  }

  return output;
}

function skippedCandidate(
  candidate: ScoredSearchCandidate,
  judgeStatus: Extract<FixedSearchJudgeStatus, "skipped_zero_score" | "skipped_candidate_limit">,
  reason: string,
): LlmMatchedSearchCandidate {
  return {
    ...candidate,
    base_score: candidate.score,
    confidence: null,
    is_match: false,
    match_bucket: "not_match",
    reason,
    judge_status: judgeStatus,
  };
}

function errorCandidate(
  candidate: ScoredSearchCandidate,
  reason: string,
  options: {
    provider?: string;
    model?: string;
    chunkId?: string;
    customId?: string;
  } = {},
): LlmMatchedSearchCandidate {
  return {
    ...candidate,
    base_score: candidate.score,
    confidence: null,
    is_match: null,
    match_bucket: null,
    reason,
    judge_status: "llm_error",
    llm_provider: normalizeSpace(options.provider),
    llm_model: normalizeSpace(options.model),
    llm_chunk_id: normalizeSpace(options.chunkId),
    llm_custom_id: normalizeSpace(options.customId),
  };
}

function parseBoundedScore(value: unknown, fallback: number) {
  const parsed = Number(value);
  if (!Number.isFinite(parsed) || parsed < 0 || parsed > 100) {
    return fallback;
  }

  return Number(parsed.toFixed(2));
}

function parseConfidence(value: unknown) {
  const parsed = Number(value);
  if (!Number.isFinite(parsed) || parsed < 0 || parsed > 1) {
    return null;
  }

  return Number(parsed.toFixed(4));
}

export function normalizeFixedSearchMatchBucket(value: unknown, isMatch: unknown): FixedSearchMatchBucket {
  const normalized = normalizeSpace(value).toLowerCase();
  if (normalized === "matched" || normalized === "possible" || normalized === "not_match") {
    return normalized;
  }

  return isMatch ? "matched" : "not_match";
}

async function writeArtifact(outputDir: string | undefined, fileName: string, content: string) {
  if (!outputDir) {
    return "";
  }
  await mkdir(outputDir, { recursive: true });
  const filePath = path.join(outputDir, fileName);
  await writeFile(filePath, content, "utf8");
  return filePath;
}

function buildFixedSearchOnlineSystemPrompt(systemPrompt: string) {
  return `${normalizeSpace(systemPrompt)}\n\n现在用户会一次提供多个候选商品 candidates。请逐个判断每个候选与采购需求的关系，并归入 match_bucket 三分类：matched=商品本体/型号/规格足够匹配目标采购物料，possible=同品牌/同系列/同类目或强相关但型号规格未精确命中，not_match=无明显采购相关性。兼容字段 is_match 仅在 matched 时为 true，possible 与 not_match 都为 false。必须只返回合法 JSON 对象，格式为 {"results":[{"index":number,"custom_id":string,"is_match":boolean,"match_bucket":"matched|possible|not_match","final_score":0到100数字,"confidence":0到1数字,"reason":"中文简短原因"}]}。results 数量必须等于 candidates 数量，保持 index 和 custom_id 不变，不要返回 Markdown。`;
}

function buildOnlineChunkRequest(params: {
  client: FixedSearchOnlineClient;
  systemPrompt: string;
  context: FixedSearchLlmContext;
  entries: CandidateWithOnlineId[];
}): FixedSearchOnlineChatRequest {
  return {
    model: params.client.model,
    messages: [
      { role: "system", content: buildFixedSearchOnlineSystemPrompt(params.systemPrompt) },
      { role: "user", content: JSON.stringify(buildFixedSearchOnlineUserPayload(params.context, params.entries)) },
    ],
    temperature: 0,
    max_tokens: DEFAULT_ONLINE_MAX_TOKENS,
    response_format: { type: "json_object" },
  };
}

function extractCompletionContent(response: unknown) {
  return normalizeSpace(
    (response as { choices?: Array<{ message?: { content?: string | null } }> })?.choices?.[0]?.message?.content,
  );
}

function parseOnlineResults(response: unknown): ParsedOnlineResult[] {
  const content = extractCompletionContent(response);
  const parsed = tryParseJsonObject(content);
  if (!parsed || !Array.isArray(parsed.results)) {
    throw new Error(`在线 LLM 返回不是合法 results JSON: ${content || "(empty)"}`);
  }

  return parsed.results as ParsedOnlineResult[];
}

function parseOnlineJudgement(
  candidate: ScoredSearchCandidate,
  result: ParsedOnlineResult,
  client: FixedSearchOnlineClient,
  chunkId: string,
): LlmMatchedSearchCandidate {
  const matchBucket = normalizeFixedSearchMatchBucket(result.match_bucket, result.is_match);
  return {
    ...candidate,
    base_score: candidate.score,
    score: parseBoundedScore(result.final_score, candidate.score),
    confidence: parseConfidence(result.confidence),
    is_match: matchBucket === "matched",
    match_bucket: matchBucket,
    reason: normalizeSpace(result.reason) || `${client.model} 在线 LLM 未给出原因`,
    judge_status: "judged",
    llm_provider: client.provider,
    llm_model: client.model,
    llm_chunk_id: chunkId,
    llm_custom_id: normalizeSpace(result.custom_id),
  };
}

function chunkEntries(entries: CandidateWithOnlineId[], chunkSize: number) {
  const chunks: CandidateWithOnlineId[][] = [];
  for (let index = 0; index < entries.length; index += chunkSize) {
    chunks.push(entries.slice(index, index + chunkSize));
  }
  return chunks;
}

async function judgeOnlineCandidates(params: {
  client: FixedSearchOnlineClient;
  context: FixedSearchLlmContext;
  entries: CandidateWithOnlineId[];
  chunkSize: number;
  concurrency: number;
  outputDir?: string;
  failoverConsecutiveErrors?: number;
  onProgress?: (progress: {
    completedChunks: number;
    totalChunks: number;
    processedCandidates: number;
    totalCandidates: number;
    provider: string;
    model: string;
    message?: string;
    event?: "chunk_completed" | "provider_failover";
    failureReason?: string;
    consecutiveFailedChunks?: number;
    failoverThreshold?: number;
  }) => void | Promise<void>;
}) {
  const resultByCandidate = new Map<ScoredSearchCandidate, LlmMatchedSearchCandidate>();
  if (params.entries.length === 0) {
    return resultByCandidate;
  }

  const systemPrompt = await resolveFixedSearchCandidateMatchSystemPrompt(params.client);
  const chunks = chunkEntries(params.entries, params.chunkSize);
  const inputLines: string[] = [];
  const outputLines: string[] = [];
  let completedChunks = 0;
  let processedCandidates = 0;
  let nextChunkIndex = 0;
  let activeChunks = 0;
  let consecutiveFailedChunks = 0;
  let failoverTriggered = false;
  const failoverThreshold = params.client.provider === "mimo"
    ? Math.max(1, params.failoverConsecutiveErrors ?? resolveFixedSearchLlmFailoverConsecutiveErrors())
    : 0;
  const abortController = new AbortController();

  async function processChunk(entries: CandidateWithOnlineId[], chunkIndex: number) {
    const chunkId = `online_${params.client.provider}_${chunkIndex + 1}`;
    const request = buildOnlineChunkRequest({
      client: params.client,
      systemPrompt,
      context: params.context,
      entries,
    });
    inputLines.push(JSON.stringify({
      chunk_id: chunkId,
      provider: params.client.provider,
      model: params.client.model,
      candidate_count: entries.length,
      request,
    }));

    const customIdToEntry = new Map(entries.map((entry) => [entry.customId, entry]));
    const indexToEntry = new Map(entries.map((entry) => [entry.index, entry]));
    let successfulJudgements = 0;
    let chunkFailureReason = "";
    try {
      const response = await params.client.createChatCompletion(request, { signal: abortController.signal });
      outputLines.push(JSON.stringify({
        chunk_id: chunkId,
        provider: params.client.provider,
        model: params.client.model,
        response,
      }));
      const parsedResults = parseOnlineResults(response);
      const seen = new Set<ScoredSearchCandidate>();
      for (const result of parsedResults) {
        const customId = normalizeSpace(result.custom_id);
        const entry = customIdToEntry.get(customId) ?? indexToEntry.get(Number(result.index));
        if (!entry) {
          continue;
        }
        seen.add(entry.candidate);
        resultByCandidate.set(entry.candidate, parseOnlineJudgement(entry.candidate, result, params.client, chunkId));
        successfulJudgements += 1;
      }
      for (const entry of entries) {
        if (!seen.has(entry.candidate)) {
          resultByCandidate.set(
            entry.candidate,
            errorCandidate(entry.candidate, "在线 LLM 未返回该候选结果", {
              provider: params.client.provider,
              model: params.client.model,
              chunkId,
              customId: entry.customId,
            }),
          );
        }
      }
      if (successfulJudgements === 0) {
        chunkFailureReason = "在线 LLM 未返回任何可用候选结果";
      }
    } catch (error) {
      const message = formatErrorMessage(error);
      chunkFailureReason = message;
      outputLines.push(JSON.stringify({
        chunk_id: chunkId,
        provider: params.client.provider,
        model: params.client.model,
        error: message,
      }));
      for (const entry of entries) {
        resultByCandidate.set(
          entry.candidate,
          errorCandidate(entry.candidate, `${params.client.model} 在线 LLM 商品匹配失败: ${message}`, {
            provider: params.client.provider,
            model: params.client.model,
            chunkId,
            customId: entry.customId,
          }),
        );
      }
    } finally {
      const chunkFailed = successfulJudgements === 0;
      consecutiveFailedChunks = chunkFailed ? consecutiveFailedChunks + 1 : 0;
      completedChunks += 1;
      processedCandidates += entries.length;
      await params.onProgress?.({
        completedChunks,
        totalChunks: chunks.length,
        processedCandidates,
        totalCandidates: params.entries.length,
        provider: params.client.provider,
        model: params.client.model,
        event: "chunk_completed",
      });

      if (
        !failoverTriggered
        && failoverThreshold > 0
        && consecutiveFailedChunks >= failoverThreshold
      ) {
        failoverTriggered = true;
        abortController.abort();
        const message = `MiMo 连续 ${consecutiveFailedChunks} 个在线匹配 chunk 无响应，已切换 DeepSeek 处理剩余候选`;
        outputLines.push(JSON.stringify({
          event: "provider_failover",
          provider: params.client.provider,
          model: params.client.model,
          consecutive_failed_chunks: consecutiveFailedChunks,
          failover_threshold: failoverThreshold,
          failure_reason: chunkFailureReason,
          message,
        }));
        await params.onProgress?.({
          completedChunks,
          totalChunks: chunks.length,
          processedCandidates,
          totalCandidates: params.entries.length,
          provider: params.client.provider,
          model: params.client.model,
          message,
          event: "provider_failover",
          failureReason: chunkFailureReason,
          consecutiveFailedChunks,
          failoverThreshold,
        });
      }
    }
  }

  const workerCount = Math.min(Math.max(1, params.concurrency), chunks.length);
  await Promise.all(Array.from({ length: workerCount }, async () => {
    while (!failoverTriggered) {
      if (consecutiveFailedChunks > 0 && activeChunks > 0) {
        return;
      }
      const chunkIndex = nextChunkIndex;
      nextChunkIndex += 1;
      const entries = chunks[chunkIndex];
      if (entries === undefined) {
        return;
      }
      activeChunks += 1;
      try {
        await processChunk(entries, chunkIndex);
      } finally {
        activeChunks -= 1;
      }
    }
  }));

  await writeArtifact(params.outputDir, `llm-online-${params.client.provider}-input.jsonl`, inputLines.join("\n"));
  await writeArtifact(params.outputDir, `llm-online-${params.client.provider}-output.jsonl`, outputLines.join("\n"));
  return resultByCandidate;
}

function trimTrailingSlash(value: string) {
  return value.replace(/\/+$/, "");
}

function providerEnvPrefix(provider: "mimo" | "deepseek") {
  return provider === "mimo" ? "MIMO" : "DEEPSEEK";
}

function defaultModelForProvider(provider: "mimo" | "deepseek") {
  return provider === "mimo" ? DEFAULT_MIMO_MODEL : DEFAULT_DEEPSEEK_MODEL;
}

function readEnvProviderConfig(
  provider: "mimo" | "deepseek",
  model: string | undefined,
): FixedSearchOnlineProviderConfig | null {
  const prefix = providerEnvPrefix(provider);
  const baseURL = normalizeSpace(process.env[`${prefix}_LLM_BASE_URL`]);
  const apiKey = normalizeSpace(process.env[`${prefix}_LLM_API_KEY`]);
  if (!baseURL || !apiKey) {
    return null;
  }
  return {
    provider,
    baseURL: trimTrailingSlash(baseURL),
    apiKey,
    model: normalizeSpace(model) || defaultModelForProvider(provider),
  };
}

function ensureAllowedProvider(config: ResolvedLlmRuntimeConfig): FixedSearchOnlineProviderConfig {
  const provider = normalizeProvider(config.provider);
  if (!provider || !ALLOWED_FIXED_SEARCH_PROVIDERS.has(provider)) {
    throw new Error(`固定搜索 LLM 仅支持 MiMo 或 DeepSeek，当前 provider=${config.provider || "unknown"}`);
  }
  return {
    ...config,
    provider,
  };
}

async function resolvePrimaryOnlineProviderConfig(): Promise<FixedSearchOnlineProviderConfig> {
  const requestedProvider = normalizeProvider(process.env.FIXED_SEARCH_LLM_PROVIDER);
  if (requestedProvider) {
    const envConfig = readEnvProviderConfig(
      requestedProvider,
      process.env.FIXED_SEARCH_LLM_MODEL,
    );
    if (envConfig) {
      return envConfig;
    }
    return ensureAllowedProvider(await resolveLlmRuntimeConfig("fixed_search_batch"));
  }

  const mimoConfig = readEnvProviderConfig("mimo", DEFAULT_MIMO_MODEL);
  if (mimoConfig) {
    return mimoConfig;
  }

  const deepseekConfig = readEnvProviderConfig("deepseek", DEFAULT_DEEPSEEK_MODEL);
  if (deepseekConfig) {
    return deepseekConfig;
  }

  return ensureAllowedProvider(await resolveLlmRuntimeConfig("fixed_search_batch"));
}

async function resolveFallbackOnlineProviderConfig(): Promise<FixedSearchOnlineProviderConfig | null> {
  const provider = normalizeProvider(process.env.FIXED_SEARCH_LLM_FALLBACK_PROVIDER) || "deepseek";
  const model = normalizeSpace(process.env.FIXED_SEARCH_LLM_FALLBACK_MODEL) || defaultModelForProvider(provider);
  return readEnvProviderConfig(provider, model);
}

function buildAuthHeaders(config: FixedSearchOnlineProviderConfig) {
  if (config.provider === "mimo") {
    return { "api-key": config.apiKey };
  }
  return { Authorization: `Bearer ${config.apiKey}` };
}

function createOnlineClientFromConfig(config: FixedSearchOnlineProviderConfig): FixedSearchOnlineClient {
  const endpoint = `${trimTrailingSlash(config.baseURL)}/chat/completions`;
  return {
    provider: config.provider,
    model: config.model,
    createChatCompletion: async (request: FixedSearchOnlineChatRequest, options?: FixedSearchOnlineRequestOptions) => {
      const timeoutMs = resolveOnlineTimeoutMs();
      const controller = new AbortController();
      const abortFromExternalSignal = () => controller.abort();
      if (options?.signal?.aborted) {
        controller.abort();
      } else {
        options?.signal?.addEventListener("abort", abortFromExternalSignal, { once: true });
      }
      const timer = setTimeout(() => controller.abort(), timeoutMs);
      try {
        const response = await fetch(endpoint, {
          method: "POST",
          headers: {
            ...buildAuthHeaders(config),
            "Content-Type": "application/json",
          },
          body: JSON.stringify(request),
          signal: controller.signal,
        });
        const text = await response.text();
        if (!response.ok) {
          throw new Error(`${config.provider} API failed with ${response.status}: ${text}`);
        }
        return JSON.parse(text) as unknown;
      } finally {
        clearTimeout(timer);
        options?.signal?.removeEventListener("abort", abortFromExternalSignal);
      }
    },
  };
}

async function createDefaultOnlineClient() {
  return createOnlineClientFromConfig(await resolvePrimaryOnlineProviderConfig());
}

async function createFallbackOnlineClient(primary: FixedSearchOnlineClient) {
  const config = await resolveFallbackOnlineProviderConfig();
  if (!config || config.provider === primary.provider) {
    return null;
  }
  return createOnlineClientFromConfig(config);
}

function buildCandidateEntries(params: {
  candidates: ScoredSearchCandidate[];
  taskId?: string;
  materialId?: string;
}) {
  return params.candidates.map((candidate, index) => ({
    index,
    customId: buildFixedSearchOnlineCustomId({
      taskId: normalizeSpace(params.taskId),
      materialId: normalizeSpace(params.materialId),
      candidateIndex: index,
    }),
    candidate,
  }));
}

function isLlmError(result: LlmMatchedSearchCandidate | undefined) {
  return result?.judge_status === "llm_error";
}

export async function judgeCandidatesWithLlm(params: {
  context: FixedSearchLlmContext;
  candidates: ScoredSearchCandidate[];
  taskId?: string;
  materialId?: string;
  outputDir?: string;
  candidateLimit?: number;
  inputTokenBudget?: number;
  onlineChunkSize?: number;
  onlineConcurrency?: number;
  failoverConsecutiveErrors?: number;
  onlineClient?: FixedSearchOnlineClient;
  fallbackOnlineClient?: FixedSearchOnlineClient | null;
  onProgress?: (progress: {
    completedChunks: number;
    totalChunks: number;
    processedCandidates: number;
    totalCandidates: number;
    provider: string;
    model: string;
    message?: string;
    event?: "chunk_completed" | "provider_failover";
    failureReason?: string;
    consecutiveFailedChunks?: number;
    failoverThreshold?: number;
  }) => void | Promise<void>;
}): Promise<LlmMatchedSearchCandidate[]> {
  const positiveCandidates = params.candidates.filter((candidate) => Number(candidate.score) > 0);
  const zeroScoreCandidates = params.candidates.filter((candidate) => Number(candidate.score) <= 0);
  const limitedCandidates = limitCandidatesForLlm(
    positiveCandidates,
    params.candidateLimit ?? resolveFixedSearchLlmCandidateLimit(),
    params.inputTokenBudget ?? resolveFixedSearchLlmInputTokenBudget(),
  );
  const limitedSet = new Set(limitedCandidates);
  const skippedLimitCandidates = positiveCandidates.filter((candidate) => !limitedSet.has(candidate));

  const client = params.onlineClient ?? await createDefaultOnlineClient();
  const entries = buildCandidateEntries({
    candidates: limitedCandidates,
    taskId: params.taskId,
    materialId: params.materialId,
  });
  let onlineResults = await judgeOnlineCandidates({
    client,
    context: params.context,
    entries,
    chunkSize: params.onlineChunkSize ?? resolveFixedSearchOnlineChunkSize(),
    concurrency: params.onlineConcurrency ?? resolveFixedSearchOnlineConcurrency(),
    outputDir: params.outputDir,
    failoverConsecutiveErrors: params.failoverConsecutiveErrors,
    onProgress: params.onProgress,
  });

  const failedEntries = entries.filter((entry) => {
    const result = onlineResults.get(entry.candidate);
    return result === undefined || isLlmError(result);
  });
  if (failedEntries.length > 0 && (params.fallbackOnlineClient !== undefined || !params.onlineClient)) {
    const fallbackClient = params.fallbackOnlineClient !== undefined
      ? params.fallbackOnlineClient
      : await createFallbackOnlineClient(client);
    if (fallbackClient) {
      const fallbackResults = await judgeOnlineCandidates({
        client: fallbackClient,
        context: params.context,
        entries: failedEntries,
        chunkSize: resolveFallbackChunkSize(),
        concurrency: resolveFallbackConcurrency(),
        outputDir: params.outputDir,
        onProgress: params.onProgress,
      });
      onlineResults = new Map([...onlineResults, ...fallbackResults]);
    }
  }

  const resultByCandidate = new Map<ScoredSearchCandidate, LlmMatchedSearchCandidate>();
  for (const entry of entries) {
    resultByCandidate.set(
      entry.candidate,
      onlineResults.get(entry.candidate) ?? errorCandidate(entry.candidate, "在线 LLM 未返回该候选结果", {
        provider: client.provider,
        model: client.model,
        customId: entry.customId,
      }),
    );
  }
  for (const candidate of zeroScoreCandidates) {
    resultByCandidate.set(
      candidate,
      skippedCandidate(candidate, "skipped_zero_score", "标题与搜索词无基础命中，跳过 LLM"),
    );
  }
  for (const candidate of skippedLimitCandidates) {
    resultByCandidate.set(
      candidate,
      skippedCandidate(candidate, "skipped_candidate_limit", "超过固定搜索 LLM 候选上限，跳过 LLM"),
    );
  }

  return params.candidates
    .map((candidate) => resultByCandidate.get(candidate))
    .filter((candidate): candidate is LlmMatchedSearchCandidate => Boolean(candidate));
}
