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

import {
  createBatchTask,
  downloadBatchFileContent,
  resolveBigModelBatchModel,
  retrieveBatchTask,
  uploadBatchFile,
  type BigModelBatchRecord,
} from "../standardization/bigmodel-batch-client.ts";

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 LlmMatchedSearchCandidate = ScoredSearchCandidate & {
  is_match: boolean | null;
  reason: string;
  judge_status: FixedSearchJudgeStatus;
  batch_id?: string;
  batch_custom_id?: string;
};

type FixedSearchBatchRequestLine = {
  custom_id: string;
  method: "POST";
  url: "/v4/chat/completions";
  body: {
    model: string;
    messages: Array<{ role: "system" | "user"; content: string }>;
    temperature: number;
    response_format: { type: "json_object" };
  };
};

type FixedSearchBatchOutputLine = {
  custom_id?: string;
  response?: {
    status_code?: number;
    body?: {
      error?: { message?: string };
      message?: string;
      choices?: Array<{
        message?: {
          content?: string;
        };
      }>;
    };
  };
  error?: {
    message?: string;
  };
};

type FixedSearchBatchClient = {
  uploadBatchFile: typeof uploadBatchFile;
  createBatchTask: typeof createBatchTask;
  retrieveBatchTask: typeof retrieveBatchTask;
  downloadBatchFileContent: typeof downloadBatchFileContent;
};

const DEFAULT_LLM_CANDIDATE_LIMIT = 500;
const DEFAULT_LLM_INPUT_TOKEN_BUDGET = 100_000;
const DEFAULT_BATCH_POLL_INTERVAL_MS = 10_000;
const DEFAULT_BATCH_MAX_WAIT_MS = 30 * 60_000;
const DEFAULT_BATCH_CLIENT: FixedSearchBatchClient = {
  uploadBatchFile,
  createBatchTask,
  retrieveBatchTask,
  downloadBatchFileContent,
};

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

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

function sleep(ms: number) {
  return new Promise((resolve) => setTimeout(resolve, ms));
}

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 batchSystemPrompt() {
  return [
    "你是建筑工地用品专业采购员，你要使用 1688 采购建筑工地用品。",
    "你只会收到 AI 推理说明和一个候选商品 JSON。",
    "只根据 AI 推理说明与候选商品标题判断候选是否为可采购的同类商品 - 限定建筑工地场景使用，一定要排除只适合家用的物品。",
    "标题与 AI 推理说明中的商品类型、系列/型号、规格主干匹配时，且标题符合建筑工地用品，非家用，应判定 is_match=true。",
    "电压、接口、通径、颜色、包装、品牌缺失或配件词不应单独导致 false，除非它们证明核心商品类型或主型号明显不符。",
    "如果组合词中有品牌，或者 AI推理说明 中明确了品牌，但是候选商品标题中没有明确对应品牌，判定 is_match=false。",
    "核心商品类型不符、应用场景不符合建筑工地用品、主型号/系列明显不符时，判定 is_match=false。",
    "必须只输出 JSON 对象，格式为 {\"is_match\":true,\"reason\":\"简洁原因\"}。",
  ].join("\n");
}

export function buildFixedSearchBatchUserPayload(context: FixedSearchLlmContext, candidate: ScoredSearchCandidate) {
  return {
    ai_reasoning: normalizeSpace(context.ai_reasoning),
    candidate: candidateInput(candidate),
  };
}

export function buildFixedSearchBatchCustomId(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 [
    "fm",
    hashToken(params.taskId, "no_task"),
    hashToken(params.materialId, "no_material"),
    String(params.candidateIndex),
  ].join("_");
}

export function buildFixedSearchBatchRequestLines(params: {
  taskId: string;
  materialId: string;
  model?: string;
  context: FixedSearchLlmContext;
  candidates: ScoredSearchCandidate[];
}): FixedSearchBatchRequestLine[] {
  const model = resolveBigModelBatchModel(params.model);
  return params.candidates.map((candidate, index) => ({
    custom_id: buildFixedSearchBatchCustomId({
      taskId: params.taskId,
      materialId: params.materialId,
      candidateIndex: index,
    }),
    method: "POST",
    url: "/v4/chat/completions",
    body: {
      model,
      messages: [
        { role: "system", content: batchSystemPrompt() },
        { role: "user", content: JSON.stringify(buildFixedSearchBatchUserPayload(params.context, candidate)) },
      ],
      temperature: 0,
      response_format: { type: "json_object" },
    },
  }));
}

export function buildFixedSearchBatchJsonl(params: Parameters<typeof buildFixedSearchBatchRequestLines>[0]) {
  return buildFixedSearchBatchRequestLines(params)
    .map((line) => JSON.stringify(line))
    .join("\n");
}

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);
}

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,
    is_match: false,
    reason,
    judge_status: judgeStatus,
  };
}

function errorCandidate(
  candidate: ScoredSearchCandidate,
  reason: string,
  batchRecord?: BigModelBatchRecord | null,
  customId?: string,
): LlmMatchedSearchCandidate {
  return {
    ...candidate,
    is_match: null,
    reason,
    judge_status: "llm_error",
    batch_id: normalizeSpace(batchRecord?.id),
    batch_custom_id: normalizeSpace(customId),
  };
}

function parseJsonlLines(content: string): FixedSearchBatchOutputLine[] {
  return content
    .split(/\r?\n/)
    .map((line) => line.trim())
    .filter(Boolean)
    .map((line) => JSON.parse(line) as FixedSearchBatchOutputLine);
}

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

async function writeBatchErrorArtifact(params: {
  outputDir?: string;
  stage: string;
  error: unknown;
  extra?: Record<string, unknown>;
}) {
  const message = formatErrorMessage(params.error);
  await writeArtifact(params.outputDir, "llm-batch-error.json", JSON.stringify({
    stage: params.stage,
    message,
    stack: params.error instanceof Error ? params.error.stack : "",
    ...params.extra,
    occurredAt: new Date().toISOString(),
  }, null, 2));
  return message;
}

function extractBatchLineError(line: FixedSearchBatchOutputLine) {
  return normalizeSpace(
    line.error?.message
      ?? line.response?.body?.error?.message
      ?? line.response?.body?.message
      ?? "",
  );
}

function parseBatchJudgement(
  candidate: ScoredSearchCandidate,
  line: FixedSearchBatchOutputLine,
  batchRecord: BigModelBatchRecord,
  model: string,
): LlmMatchedSearchCandidate {
  const statusCode = Number(line.response?.status_code ?? 0);
  const lineError = extractBatchLineError(line);
  if (line.error || (statusCode && statusCode >= 400) || lineError) {
    return errorCandidate(candidate, lineError || `Batch 行级失败: HTTP ${statusCode || "unknown"}`, batchRecord, line.custom_id);
  }

  const content = normalizeSpace(line.response?.body?.choices?.[0]?.message?.content);
  const parsed = tryParseJsonObject(content);
  if (!parsed) {
    return errorCandidate(candidate, `Batch 返回不是合法 JSON: ${content || "(empty)"}`, batchRecord, line.custom_id);
  }

  return {
    ...candidate,
    is_match: Boolean(parsed.is_match),
    reason: normalizeSpace(parsed.reason) || `${model} Batch 未给出原因`,
    judge_status: "judged",
    batch_id: normalizeSpace(batchRecord.id),
    batch_custom_id: normalizeSpace(line.custom_id),
  };
}

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

async function waitForBatchCompletion(params: {
  batchId: string;
  client: FixedSearchBatchClient;
  pollIntervalMs: number;
  maxWaitMs: number;
  onProgress?: (progress: {
    batchId: string;
    status: string;
    completed: number;
    total: number;
    failed: number;
  }) => void | Promise<void>;
  outputDir?: string;
}) {
  const startedAt = Date.now();
  let latest = await params.client.retrieveBatchTask(params.batchId);

  while (true) {
    await params.onProgress?.({
      batchId: latest.id,
      status: latest.status,
      completed: Number(latest.completed ?? 0),
      total: Number(latest.total ?? 0),
      failed: Number(latest.failed ?? 0),
    });
    await writeArtifact(params.outputDir, "llm-batch-record.json", JSON.stringify(latest, null, 2));

    if (latest.status === "completed") {
      return latest;
    }

    if (["failed", "expired", "cancelled", "cancelling"].includes(latest.status)) {
      return latest;
    }

    if (Date.now() - startedAt > params.maxWaitMs) {
      return {
        ...latest,
        status: "client_timeout",
      } satisfies BigModelBatchRecord;
    }

    await sleep(params.pollIntervalMs);
    latest = await params.client.retrieveBatchTask(params.batchId);
  }
}

async function judgeBatchCandidates(params: {
  taskId: string;
  materialId: string;
  context: FixedSearchLlmContext;
  candidates: ScoredSearchCandidate[];
  outputDir?: string;
  client: FixedSearchBatchClient;
  pollIntervalMs: number;
  maxWaitMs: number;
  onBatchComplete?: (progress: {
    completedBatches: number;
    totalBatches: number;
    processedCandidates: number;
    totalCandidates: number;
    batchId?: string;
    batchStatus?: string;
  }) => void | Promise<void>;
}) {
  if (params.candidates.length === 0) {
    await writeArtifact(params.outputDir, "llm-batch-input.jsonl", "");
    return new Map<ScoredSearchCandidate, LlmMatchedSearchCandidate>();
  }

  const model = resolveBigModelBatchModel();
  const requestLines = buildFixedSearchBatchRequestLines({
    taskId: params.taskId,
    materialId: params.materialId,
    model,
    context: params.context,
    candidates: params.candidates,
  });
  const requestJsonl = requestLines.map((line) => JSON.stringify(line)).join("\n");
  await writeArtifact(params.outputDir, "llm-batch-input.jsonl", requestJsonl);

  const failAllCandidates = (
    stage: string,
    message: string,
    batchRecord?: BigModelBatchRecord | null,
  ) => {
    const resultByCandidate = new Map<ScoredSearchCandidate, LlmMatchedSearchCandidate>();
    const reason = `${model} 离线 Batch 商品匹配失败: ${stage}; ${message}`;
    for (const [index, candidate] of params.candidates.entries()) {
      resultByCandidate.set(candidate, errorCandidate(candidate, reason, batchRecord, requestLines[index]?.custom_id));
    }
    return resultByCandidate;
  };

  let uploadedFile: Awaited<ReturnType<FixedSearchBatchClient["uploadBatchFile"]>>;
  try {
    uploadedFile = await params.client.uploadBatchFile({
      filename: `fixed-search-llm-${params.taskId || "task"}-${params.materialId || "material"}.jsonl`,
      content: requestJsonl,
    });
    await writeArtifact(params.outputDir, "llm-batch-uploaded-file.json", JSON.stringify(uploadedFile, null, 2));
  } catch (error) {
    const message = await writeBatchErrorArtifact({
      outputDir: params.outputDir,
      stage: "upload",
      error,
      extra: { requestLineCount: requestLines.length },
    });
    return failAllCandidates("upload", message);
  }

  let createdBatch: BigModelBatchRecord;
  try {
    createdBatch = await params.client.createBatchTask({
      inputFileId: uploadedFile.id,
      endpoint: "/v4/chat/completions",
      autoDeleteInputFile: false,
      metadata: {
        source: "fixed_search_llm_match",
        task_id: params.taskId,
        material_id: params.materialId,
        model,
      },
    });
    await writeArtifact(params.outputDir, "llm-batch-record.json", JSON.stringify(createdBatch, null, 2));
  } catch (error) {
    const message = await writeBatchErrorArtifact({
      outputDir: params.outputDir,
      stage: "create",
      error,
      extra: { uploadedFile },
    });
    return failAllCandidates("create", message);
  }

  let batchRecord: BigModelBatchRecord;
  try {
    batchRecord = await waitForBatchCompletion({
      batchId: createdBatch.id,
      client: params.client,
      pollIntervalMs: params.pollIntervalMs,
      maxWaitMs: params.maxWaitMs,
      outputDir: params.outputDir,
      onProgress: async (progress) => {
        await params.onBatchComplete?.({
          completedBatches: progress.completed,
          totalBatches: progress.total || params.candidates.length,
          processedCandidates: progress.completed,
          totalCandidates: progress.total || params.candidates.length,
          batchId: progress.batchId,
          batchStatus: progress.status,
        });
      },
    });
  } catch (error) {
    const message = await writeBatchErrorArtifact({
      outputDir: params.outputDir,
      stage: "poll",
      error,
      extra: { batchId: createdBatch.id },
    });
    return failAllCandidates("poll", message, createdBatch);
  }

  const resultByCandidate = new Map<ScoredSearchCandidate, LlmMatchedSearchCandidate>();
  const customIdToCandidate = new Map<string, ScoredSearchCandidate>();
  for (const [index, candidate] of params.candidates.entries()) {
    customIdToCandidate.set(requestLines[index]!.custom_id, candidate);
  }

  if (batchRecord.status !== "completed") {
    let errorContent = "";
    if (batchRecord.error_file_id) {
      errorContent = await params.client.downloadBatchFileContent(batchRecord.error_file_id).catch((error) => (
        `下载 Batch 错误文件失败: ${error instanceof Error ? error.message : String(error)}`
      ));
      await writeArtifact(params.outputDir, "llm-batch-error.jsonl", errorContent);
    }
    await writeArtifact(params.outputDir, "llm-batch-error.json", JSON.stringify({
      stage: "batch_status",
      status: batchRecord.status,
      batchRecord,
      errorPreview: errorContent.slice(0, 1000),
      occurredAt: new Date().toISOString(),
    }, null, 2));
    const reason = `${model} 离线 Batch 商品匹配失败: ${batchRecord.status}${errorContent ? `; ${errorContent.slice(0, 300)}` : ""}`;
    for (const candidate of params.candidates) {
      resultByCandidate.set(candidate, errorCandidate(candidate, reason, batchRecord));
    }
    return resultByCandidate;
  }

  let outputContent = "";
  try {
    outputContent = batchRecord.output_file_id
      ? await params.client.downloadBatchFileContent(batchRecord.output_file_id)
      : "";
  } catch (error) {
    const message = await writeBatchErrorArtifact({
      outputDir: params.outputDir,
      stage: "download_output",
      error,
      extra: { batchRecord },
    });
    return failAllCandidates("download_output", message, batchRecord);
  }
  await writeArtifact(params.outputDir, "llm-batch-output.jsonl", outputContent);

  let outputLines: FixedSearchBatchOutputLine[] = [];
  try {
    outputLines = parseJsonlLines(outputContent);
  } catch (error) {
    const message = await writeBatchErrorArtifact({
      outputDir: params.outputDir,
      stage: "parse_output",
      error,
      extra: { outputPreview: outputContent.slice(0, 1000) },
    });
    return failAllCandidates("parse_output", message, batchRecord);
  }
  const seenCustomIds = new Set<string>();
  for (const line of outputLines) {
    const customId = normalizeSpace(line.custom_id);
    if (!customId) {
      continue;
    }
    seenCustomIds.add(customId);
    const candidate = customIdToCandidate.get(customId);
    if (!candidate) {
      continue;
    }
    resultByCandidate.set(candidate, parseBatchJudgement(candidate, line, batchRecord, model));
  }

  for (const [customId, candidate] of customIdToCandidate.entries()) {
    if (!seenCustomIds.has(customId)) {
      resultByCandidate.set(candidate, errorCandidate(candidate, "Batch 未返回该候选结果", batchRecord, customId));
    }
  }

  return resultByCandidate;
}

export async function judgeCandidatesWithLlm(params: {
  context: FixedSearchLlmContext;
  candidates: ScoredSearchCandidate[];
  taskId?: string;
  materialId?: string;
  outputDir?: string;
  candidateLimit?: number;
  inputTokenBudget?: number;
  batchPollIntervalMs?: number;
  batchMaxWaitMs?: number;
  batchClient?: FixedSearchBatchClient;
  onBatchComplete?: (progress: {
    completedBatches: number;
    totalBatches: number;
    processedCandidates: number;
    totalCandidates: number;
    batchId?: string;
    batchStatus?: string;
  }) => 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 batchResults = await judgeBatchCandidates({
    taskId: normalizeSpace(params.taskId),
    materialId: normalizeSpace(params.materialId),
    context: params.context,
    candidates: limitedCandidates,
    outputDir: params.outputDir,
    client: params.batchClient ?? DEFAULT_BATCH_CLIENT,
    pollIntervalMs: parsePositiveInteger(params.batchPollIntervalMs ?? process.env.FIXED_SEARCH_LLM_BATCH_POLL_INTERVAL_MS, DEFAULT_BATCH_POLL_INTERVAL_MS),
    maxWaitMs: parsePositiveInteger(params.batchMaxWaitMs ?? process.env.FIXED_SEARCH_LLM_BATCH_MAX_WAIT_MS, DEFAULT_BATCH_MAX_WAIT_MS),
    onBatchComplete: params.onBatchComplete,
  });

  const resultByCandidate = new Map<ScoredSearchCandidate, LlmMatchedSearchCandidate>();
  for (const candidate of limitedCandidates) {
    resultByCandidate.set(
      candidate,
      batchResults.get(candidate) ?? errorCandidate(candidate, "Batch 未返回该候选结果"),
    );
  }
  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));
}
