import "dotenv/config";

import { spawn } from "node:child_process";
import { readdir, readFile, writeFile, mkdtemp, mkdir } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import process from "node:process";
import { pathToFileURL } from "node:url";

import type { SearchPipelineConfig } from "../core/types.ts";
import { runImageKeywordSelection } from "../keywords/keyword-selection.ts";
import { generateKeywordArtifacts } from "../keywords/keyword-artifact-generator.ts";
import { buildMerchantKey, type SearchExecutionStage } from "./multi-stage-results.ts";

export const AUTOMATION_DIR = path.resolve(import.meta.dirname!, "..");
export const PROJECT_ROOT = path.resolve(AUTOMATION_DIR, "..");
const SEARCH_TASK_OUTPUT_ROOT = process.env.SEARCH_TASK_OUTPUT_ROOT
  ? path.resolve(process.env.SEARCH_TASK_OUTPUT_ROOT)
  : path.join(AUTOMATION_DIR, "output");
export const SEARCH_PLAN_OUTPUT_ROOT = path.join(SEARCH_TASK_OUTPUT_ROOT, "search-plan");
export const SEARCH_PLAN_FOLLOWUP_OUTPUT_ROOT = path.join(SEARCH_TASK_OUTPUT_ROOT, "search-plan-followup");
export const IMAGE_SEARCH_OUTPUT_ROOT = path.join(SEARCH_TASK_OUTPUT_ROOT, "image-search-keywords");
export const FINAL_RESULTS_DIR = path.join(SEARCH_TASK_OUTPUT_ROOT, "final-results");
const DEFAULT_CONTACT_LIMIT = Number.parseInt(process.env.SEARCH_CONTACT_LIMIT ?? "10", 10);
export const FIXED_RUNTIME_RESULTS_PER_PAGE = 60;

export function resolveFixedRuntimePageCount(value: unknown = process.env.SEARCH_FIXED_RUNTIME_PAGE_COUNT) {
  const parsed = Number(String(value ?? "").trim());
  return Number.isInteger(parsed) && parsed > 0 ? parsed : 3;
}

export function resolveFixedRuntimeCandidateLimit(pageCount = resolveFixedRuntimePageCount()) {
  return pageCount * FIXED_RUNTIME_RESULTS_PER_PAGE;
}

export function log(message: string) {
  console.log(`[row-pipeline] ${message}`);
}

export function applyMerchantLimit<T>(items: T[], limit: number) {
  return Number.isInteger(limit) && limit > 0 ? items.slice(0, limit) : items;
}

export type RunOptions = {
  materialId: string;
  batchId: string;
  headless: boolean;
  contactLimit: number;
};

export function parseArgs(argv: string[]) {
  const options = {
    materialId: '',
    batchId: '',
    headless: (process.env.SEARCH_HEADLESS ?? "0") === "1",
    contactLimit: DEFAULT_CONTACT_LIMIT,
  };

  for (const arg of argv) {
    if (!arg.startsWith("--")) {
      continue;
    }

    const [flag, rawValue] = arg.slice(2).split("=", 2);
    const value = rawValue ?? "";

    if (flag === "material-id" && value) {
      options.materialId = value;
    } else if (flag === "batch-id" && value) {
      options.batchId = value;
    } else if (flag === "headless" && value) {
      options.headless = value === "1" || value === "true";
    } else if (flag === "contact-limit" && value) {
      options.contactLimit = Number.parseInt(value, 10);
    }
  }

  if (!options.materialId) {
    throw new Error("参数 --material-id 必填，例如 --material-id=<uuid>");
  }

  if (!Number.isInteger(options.contactLimit) || options.contactLimit < 0) {
    throw new Error("参数 --contact-limit 必须是大于等于 0 的整数，例如 --contact-limit=10");
  }

  return options;
}

export async function listRunDirs(rootDir: string) {
  try {
    const entries = await readdir(rootDir, { withFileTypes: true });
    return entries
      .filter((entry) => entry.isDirectory() && entry.name.startsWith("run-"))
      .map((entry) => path.join(rootDir, entry.name))
      .sort();
  } catch {
    return [];
  }
}

export function pickNewRunDir(beforeRunDirs: string[], afterRunDirs: string[]) {
  return afterRunDirs.find((dir) => !beforeRunDirs.includes(dir)) ?? afterRunDirs.at(-1) ?? "";
}

export async function runNodeScript(options: {
  scriptPath: string;
  args: string[];
  cwd: string;
  label: string;
}) {
  log(`开始执行: ${options.label}`);

  return new Promise<{ stdout: string; stderr: string }>((resolve, reject) => {
    const MAX_BUFFER_BYTES = 10 * 1024 * 1024;
    let stdout = "";
    let stderr = "";

    const appendOutput = (current: string, chunk: Buffer | string) => {
      const text = typeof chunk === "string" ? chunk : chunk.toString("utf8");
      if (!text) {
        return current;
      }

      const remainingBytes = MAX_BUFFER_BYTES - Buffer.byteLength(current, "utf8");
      if (remainingBytes <= 0) {
        return current;
      }

      const textBuffer = Buffer.from(text, "utf8");
      if (textBuffer.byteLength <= remainingBytes) {
        return current + text;
      }

      return current + textBuffer.subarray(0, remainingBytes).toString("utf8");
    };

    const child = spawn(process.execPath, [options.scriptPath, ...options.args], {
      cwd: options.cwd,
      stdio: ["ignore", "pipe", "pipe"],
    });

    child.stdout?.on("data", (chunk) => {
      stdout = appendOutput(stdout, chunk);
      process.stdout.write(chunk);
    });

    child.stderr?.on("data", (chunk) => {
      stderr = appendOutput(stderr, chunk);
      process.stderr.write(chunk);
    });

    child.once("error", (error) => {
      reject(error);
    });

    child.once("close", (code, signal) => {
      if (code === 0) {
        log(`执行完成: ${options.label}`);
        resolve({
          stdout: stdout.trim(),
          stderr: stderr.trim(),
        });
        return;
      }

      const message = `${options.label} 执行失败: exit ${code ?? "null"}${signal ? ` signal ${signal}` : ""}`;
      reject(new Error(
        `${message}`
        + `${stdout.trim() ? `\n[stdout]\n${stdout.trim()}` : ""}`
        + `${stderr.trim() ? `\n[stderr]\n${stderr.trim()}` : ""}`,
      ));
    });
  });
}

export function pickValidRound(rounds: Array<Record<string, unknown>>) {
  return rounds.find((item) => Boolean(item.is_valid_search)) ?? null;
}

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

function hasDetectedPhoneContactValue(contacts: unknown) {
  return (Array.isArray(contacts) ? contacts : []).some((contact) => {
    if (!contact || typeof contact !== "object") {
      return false;
    }

    const record = contact as Record<string, unknown>;
    const type = normalizeText(record.type).toLowerCase();
    const value = normalizeText(record.value);
    return (type === "phone" || type === "mobile")
      && value.length > 0
      && !/^(?:暂无|无|空|--|-|保密|未填写|未公开|暂无信息|暂未填写|n\/?a|none|null)$/i.test(value);
  });
}

export function normalizeProductFromRaw(item: Record<string, unknown>, fallbackIndex: number) {
  const detectedContacts = Array.isArray(item.detectedContacts)
    ? item.detectedContacts
    : Array.isArray(item.detected_contacts)
      ? item.detected_contacts
      : [];
  const hasPhoneContact = Boolean(item.hasPhoneContact ?? item.has_phone_contact ?? hasDetectedPhoneContactValue(detectedContacts));
  const qualificationStatus = String(
    item.qualificationStatus
    ?? item.qualification_status
    ?? (hasPhoneContact ? "qualified" : "rejected_no_phone"),
  );

  return {
    index: Number(item.index ?? fallbackIndex),
    title: String(item.title ?? "").replace(/\s+/g, " ").trim(),
    offer_url: String(item.offerUrl ?? item.offer_url ?? ""),
    image_url: String(item.imageUrl ?? item.image_url ?? ""),
    merchant_name: String(item.merchantName ?? item.merchant_name ?? "").replace(/\s+/g, " ").trim(),
    merchant_url: String(item.merchantUrl ?? item.merchant_url ?? ""),
    wangwang_url: String(item.wangwang_url ?? (item.contact as Record<string, unknown> | undefined)?.url ?? ""),
    offer_screenshot_path: String(item.offer_screenshot_path ?? item.offerScreenshotPath ?? ""),
    contact_page_url: String(item.contactPageUrl ?? item.contact_page_url ?? ""),
    detected_contacts: detectedContacts,
    has_phone_contact: hasPhoneContact,
    qualification_status: qualificationStatus,
    contact_collection_reason: String(item.contactCollectionReason ?? item.contact_collection_reason ?? ""),
  };
}

export async function readResultsFromPath(resultsPath: string, limit = 0) {
  if (!resultsPath) return [];
  try {
    const raw = JSON.parse(await readFile(resultsPath, "utf8"));
    return applyMerchantLimit(Array.isArray(raw) ? raw : [], limit).map(
      (item, i) => normalizeProductFromRaw(item as Record<string, unknown>, i + 1),
    );
  } catch {
    return [];
  }
}

export async function writeFinalResult(
  materialId: string,
  source: string,
  mainKeyword: string,
  products: unknown[],
) {
  await mkdir(FINAL_RESULTS_DIR, { recursive: true });
  const outputPath = path.join(FINAL_RESULTS_DIR, `material-${materialId}.json`);
  await writeFile(
    outputPath,
    JSON.stringify(
      { material_id: materialId, finished_at: new Date().toISOString(), source, main_keyword: mainKeyword, product_count: products.length, products },
      null,
      2,
    ),
    "utf8",
  );
  return outputPath;
}

// ── 阶段函数：每个阶段可被 row-pipeline-sync.ts 独立调用 ─────

export type KeywordStageResult = Awaited<ReturnType<typeof generateKeywordArtifacts>>;

export async function stageKeywords(materialId: string, batchId?: string, pipelineConfig?: SearchPipelineConfig): Promise<KeywordStageResult> {
  const result = await generateKeywordArtifacts({ materialId, batchId, pipelineConfig });
  log(`关键词阶段完成: ${result.searchPlanPath}`);
  return result;
}

export type InitialSearchResult = {
  searchRunDir: string;
  searchRunSummaryPath: string;
  searchRunSummary: Record<string, unknown>;
  initialRounds: Array<Record<string, unknown>>;
  validRound: Record<string, unknown> | null;
  bestMatchImage: Record<string, unknown> | null;
};

export function normalizeSearchPlanRoundFailureReason(value: unknown) {
  const normalized = normalizeText(value).toLowerCase();

  if (
    normalized === "captcha_intercept"
    || normalized === "search_service_error"
    || normalized === "login_intercept"
    || normalized === "round_failed"
  ) {
    return normalized;
  }

  return "";
}

function hasRoundResultsArtifacts(round: Record<string, unknown>) {
  if (normalizeText(round.results_path).length > 0) {
    return true;
  }

  return Array.isArray(round.results) && round.results.length > 0;
}

export function canReuseInitialSearchArtifacts(search: Pick<InitialSearchResult, "initialRounds" | "validRound">) {
  if (search.validRound) {
    return true;
  }

  return search.initialRounds.some((round) => {
    if (hasRoundResultsArtifacts(round)) {
      return true;
    }

    return normalizeSearchPlanRoundFailureReason(round.round_failure_reason) === "captcha_intercept";
  });
}

async function loadInitialSearchArtifacts(searchRunDir: string, materialId: string): Promise<InitialSearchResult> {
  const searchRunSummaryPath = path.join(searchRunDir, "run-summary.json");
  const searchRunSummary = JSON.parse(await readFile(searchRunSummaryPath, "utf8"));
  const initialRounds = Array.isArray(searchRunSummary.rounds)
    ? searchRunSummary.rounds.filter((item: { material_id?: string }) => String(item.material_id ?? "") === materialId)
    : [];
  const validRound = pickValidRound(initialRounds);

  const bestMatchImage = Array.isArray(searchRunSummary.best_match_images)
    ? searchRunSummary.best_match_images.find((item: { material_id?: string }) => String(item.material_id ?? "") === materialId) ?? null
    : null;

  return { searchRunDir, searchRunSummaryPath, searchRunSummary, initialRounds, validRound, bestMatchImage };
}

export async function stageInitialSearch(
  options: RunOptions,
  keywordResult: KeywordStageResult,
  stage?: Extract<SearchExecutionStage, { kind: "keyword" }>,
): Promise<InitialSearchResult> {
  const searchBeforeRuns = await listRunDirs(SEARCH_PLAN_OUTPUT_ROOT);
  const fixedRuntimeStage = stage?.mode === "fixed_runtime";
  const fixedRuntimeCandidateLimit = resolveFixedRuntimeCandidateLimit();
  const searchResultLimit = fixedRuntimeStage ? fixedRuntimeCandidateLimit : options.contactLimit;
  const validationResultLimit = fixedRuntimeStage ? fixedRuntimeCandidateLimit : 60;
  const contactLimit = fixedRuntimeStage ? 0 : options.contactLimit;
  const brandGuardKeyword = stage?.requiresBrandHitGuard
    ? String(keywordResult.analysis?.brand_term ?? "").trim()
    : "";
  let scriptError: Error | null = null;
  try {
    await runNodeScript({
      scriptPath: path.join(AUTOMATION_DIR, "playwright", "search-plan.js"),
      cwd: PROJECT_ROOT,
      label: "首轮 1688 文字搜索",
      args: [
        `--input=${keywordResult.searchPlanPath}`,
        `--output=${SEARCH_PLAN_OUTPUT_ROOT}`,
        `--material-id=${options.materialId}`,
        `--result-limit=${searchResultLimit}`,
        `--validation-result-limit=${validationResultLimit}`,
        "--validation-scroll-steps=12",
        `--contact-limit=${contactLimit}`,
        `--require-phone-contact=${fixedRuntimeStage ? 0 : 1}`,
        `--stage-mode=${stage?.mode ?? "default"}`,
        `--strict-hit-only=${stage?.mode === "configured" ? 1 : 0}`,
        `--always-enrich-contacts=${stage?.mode === "configured" ? 1 : 0}`,
        ...(brandGuardKeyword ? [`--brand-guard-keyword=${brandGuardKeyword}`] : []),
        `--headless=${options.headless ? 1 : 0}`,
      ],
    });
  } catch (error) {
    scriptError = error instanceof Error ? error : new Error(String(error));
  }

  const searchAfterRuns = await listRunDirs(SEARCH_PLAN_OUTPUT_ROOT);
  const searchRunDir = pickNewRunDir(searchBeforeRuns, searchAfterRuns);
  if (!searchRunDir) {
    if (scriptError) {
      throw scriptError;
    }
    throw new Error("搜索计划执行未生成运行目录");
  }

  let searchResult: InitialSearchResult;
  try {
    searchResult = await loadInitialSearchArtifacts(searchRunDir, options.materialId);
  } catch (error) {
    if (scriptError) {
      throw scriptError;
    }
    throw error;
  }

  if (scriptError) {
    if (!canReuseInitialSearchArtifacts(searchResult)) {
      throw scriptError;
    }

    const headline = scriptError.message.split("\n").map((line) => line.trim()).find(Boolean) ?? "搜索脚本异常退出";
    log(`首轮 1688 文字搜索异常退出，但已生成可复用阶段产物，继续后续流程: ${headline}`);
  }

  return searchResult;
}

export type ImageSearchResult = {
  imageRunDir: string;
  imageSearchReportPath: string;
  isValidImageSearch: boolean;
};

export async function stageImageSearch(
  options: RunOptions,
  searchRunSummaryPath: string,
  keywordResult: KeywordStageResult,
): Promise<ImageSearchResult> {
  const imageBeforeRuns = await listRunDirs(IMAGE_SEARCH_OUTPUT_ROOT);
  await runNodeScript({
    scriptPath: path.join(AUTOMATION_DIR, "playwright", "image-search-keywords.js"),
    cwd: PROJECT_ROOT,
    label: "1688 图搜与回接搜索",
    args: [
      `--run-summary=${searchRunSummaryPath}`,
      `--material-id=${options.materialId}`,
      `--output=${IMAGE_SEARCH_OUTPUT_ROOT}`,
      `--keyword-process=${keywordResult.keywordProcessPath}`,
      `--contact-limit=${options.contactLimit}`,
      `--headless=${options.headless ? 1 : 0}`,
      `--skip-follow-up`,
    ],
  });

  const imageAfterRuns = await listRunDirs(IMAGE_SEARCH_OUTPUT_ROOT);
  const imageRunDir = pickNewRunDir(imageBeforeRuns, imageAfterRuns);
  if (!imageRunDir) {
    throw new Error("image-search-keywords 未生成运行目录");
  }

  const imageSearchReportPath = path.join(imageRunDir, "image-search-keyword-report.json");

  let isValidImageSearch = true;
  try {
    const imageSearchValidationData = JSON.parse(await readFile(imageSearchReportPath, "utf8"));
    isValidImageSearch = imageSearchValidationData.image_search?.is_valid_image_search !== false;
  } catch {
    isValidImageSearch = true;
  }

  return { imageRunDir, imageSearchReportPath, isValidImageSearch };
}

export type KeywordSelectionResult = {
  mainKeyword: string;
  outputPath: string;
  debugPath: string;
};

export async function stageKeywordSelection(
  imageSearchReportPath: string,
  keywordProcessPath: string,
  imageRunDir: string,
): Promise<KeywordSelectionResult> {
  const result = await runImageKeywordSelection({
    inputPath: imageSearchReportPath,
    keywordProcessPath,
    outputRoot: imageRunDir,
    createRunDir: false,
    resultFileName: "keyword-selection-report.json",
    debugFileName: "keyword-selection-debug.json",
  });

  const mainKeyword = result.finalResult.主关键词;
  log(`主关键词确定: ${mainKeyword}`);

  return { mainKeyword, outputPath: result.outputPath, debugPath: result.debugPath };
}

export type FollowUpSearchResult = {
  followUpRunDir: string;
  followUpValid: Record<string, unknown> | null;
};

export async function stageFollowUpSearch(
  options: RunOptions,
  mainKeyword: string,
): Promise<FollowUpSearchResult> {
  log(`开始回接文字搜索`);
  const followUpTempDir = await mkdtemp(path.join(os.tmpdir(), `1688-followup-${options.materialId}-`));
  const followUpSearchPlanPath = path.join(followUpTempDir, "follow-up-search-plan.json");
  const followUpRow = {
    material_id: options.materialId,
    primary_keyword: mainKeyword,
    alt_keywords: "",
    search_round: 1,
    search_strategy: "image_search_main_keyword",
    keyword_source: "image_search_follow_up",
    confirmed: null,
    best_keyword: "",
    estimated_price_range: "",
    category: "",
  };
  await writeFile(followUpSearchPlanPath, JSON.stringify([followUpRow], null, 2), "utf8");

  const followUpBeforeRuns = await listRunDirs(SEARCH_PLAN_FOLLOWUP_OUTPUT_ROOT);
  await runNodeScript({
    scriptPath: path.join(AUTOMATION_DIR, "playwright", "search-plan.js"),
    cwd: PROJECT_ROOT,
    label: "主关键词回接文字搜索",
    args: [
      `--input=${followUpSearchPlanPath}`,
      `--output=${SEARCH_PLAN_FOLLOWUP_OUTPUT_ROOT}`,
      `--material-id=${options.materialId}`,
      `--result-limit=${options.contactLimit}`,
      "--validation-result-limit=60",
      "--validation-scroll-steps=12",
      `--contact-limit=${options.contactLimit}`,
      `--headless=${options.headless ? 1 : 0}`,
    ],
  });

  const followUpAfterRuns = await listRunDirs(SEARCH_PLAN_FOLLOWUP_OUTPUT_ROOT);
  const followUpRunDir = pickNewRunDir(followUpBeforeRuns, followUpAfterRuns);
  let followUpValid: Record<string, unknown> | null = null;
  if (followUpRunDir) {
    try {
      const summary = JSON.parse(await readFile(path.join(followUpRunDir, "run-summary.json"), "utf8"));
      const rounds = Array.isArray(summary.rounds)
        ? summary.rounds.filter((r: { material_id?: string }) => String(r.material_id ?? "") === options.materialId)
        : [];
      followUpValid = pickValidRound(rounds);
    } catch {
      followUpValid = null;
    }
  }

  return { followUpRunDir: followUpRunDir ?? "", followUpValid };
}

// ── main()：薄调度器，行为与重构前 100% 一致 ──────────────────

async function main() {
  const options = parseArgs(process.argv.slice(2));

  // ── 阶段 1：关键词生成
  const keywordResult = await stageKeywords(options.materialId, options.batchId || undefined);

  // ── 阶段 2：首轮文字搜索
  const search = await stageInitialSearch(options, keywordResult);

  // 聚合所有轮次的结果（每轮都已持久化），按商家去重累计
  const allRoundsWithResults = search.initialRounds.filter(
    (round) => String(round.results_path ?? "").length > 0,
  );
  const aggregatedProducts: ReturnType<typeof normalizeProductFromRaw>[] = [];
  if (allRoundsWithResults.length > 0) {
    const seenKeys = new Set<string>();
    for (const round of allRoundsWithResults) {
      const roundProducts = await readResultsFromPath(String(round.results_path ?? ""), options.contactLimit);
      for (const product of roundProducts) {
        const key = buildMerchantKey(product);
        if (key && seenKeys.has(key)) continue;
        if (key) seenKeys.add(key);
        aggregatedProducts.push(product);
      }
    }
  }

  if (search.validRound || aggregatedProducts.length > 0) {
    const mainKeyword = String(
      search.validRound?.primary_keyword
        ?? allRoundsWithResults[0]?.primary_keyword
        ?? "",
    );
    const finalPath = await writeFinalResult(
      options.materialId,
      "keyword_search",
      mainKeyword,
      aggregatedProducts,
    );
    console.log("\n╔════════════════════ 正式联调结果 ════════════════════╗");
    console.log(`  物料ID:               ${options.materialId}`);
    console.log(`  关键词目录:           ${keywordResult.runDir}`);
    console.log(`  文字搜索目录:         ${search.searchRunDir}`);
    console.log(`  流程状态:             文字搜索${search.validRound ? "命中" : "聚合"}，已保存`);
    console.log(`  命中轮次:             ${search.validRound?.search_round ?? "无（聚合全部轮次）"}`);
    console.log(`  最终结果:             ${finalPath} (${aggregatedProducts.length} 条商品)`);
    console.log("══════════════════════════════════════════════════════\n");
    if (search.validRound) return;
  }

  if (!search.bestMatchImage?.image_path) {
    throw new Error(`文字搜索全部无效，且 material_id=${options.materialId} 没有生成 best match 图片，无法继续图搜`);
  }

  // ── 阶段 3：图搜
  const img = await stageImageSearch(options, search.searchRunSummaryPath, keywordResult);

  if (!img.isValidImageSearch) {
    log(`图搜结果判定无效（商品不统一），回退 extended-best-matches`);
    const extendedReportPath = path.join(search.searchRunDir, "extended-best-matches", "extended-best-matches-report.json");
    let extendedRecords: unknown[] = [];
    try {
      const extendedReport = JSON.parse(await readFile(extendedReportPath, "utf8"));
      extendedRecords = Array.isArray(extendedReport.records)
        ? applyMerchantLimit(extendedReport.records, options.contactLimit)
        : [];
    } catch {
      extendedRecords = [];
    }
    const products = extendedRecords.map((item, i) => normalizeProductFromRaw(item as Record<string, unknown>, i + 1));
    const finalPath = await writeFinalResult(options.materialId, "extended_best_match_fallback", "", products);
    console.log("\n╔════════════════════ 正式联调结果 ════════════════════╗");
    console.log(`  测试行号:             第 ${options.row} 行`);
    console.log(`  关键词目录:           ${keywordResult.runDir}`);
    console.log(`  文字搜索目录:         ${search.searchRunDir}`);
    console.log(`  图搜目录:             ${img.imageRunDir}`);
    console.log(`  流程状态:             图搜结果不统一（商品类目发散），回退 extended-best-matches`);
    console.log(`  最终结果:             ${finalPath} (${products.length} 条商品)`);
    console.log("══════════════════════════════════════════════════════\n");
    return;
  }

  // ── 阶段 4：主关键词选择
  const sel = await stageKeywordSelection(img.imageSearchReportPath, keywordResult.keywordProcessPath, img.imageRunDir);

  // 检查主关键词是否已在首轮文字搜索中出现过（且全部无效），若是则跳过回接
  const triedKeywords = new Set(
    search.initialRounds.map((r: Record<string, unknown>) => String(r.primary_keyword ?? "").trim().toLowerCase()),
  );
  if (triedKeywords.has(sel.mainKeyword.trim().toLowerCase())) {
    log(`主关键词「${sel.mainKeyword}」已在首轮搜索中出现且无效，跳过回接，直接使用图搜兜底`);
    const imageSearchReport = JSON.parse(await readFile(img.imageSearchReportPath, "utf8"));
    const fallbackProducts = Array.isArray(imageSearchReport.similar_products)
      ? applyMerchantLimit(imageSearchReport.similar_products, options.contactLimit).map(
        (item, i) => normalizeProductFromRaw(item as Record<string, unknown>, i + 1),
      )
      : [];
    const finalPath = await writeFinalResult(options.row, "image_search_fallback", sel.mainKeyword, fallbackProducts);
    console.log("\n╔════════════════════ 正式联调结果 ════════════════════╗");
    console.log(`  测试行号:             第 ${options.row} 行`);
    console.log(`  关键词目录:           ${keywordResult.runDir}`);
    console.log(`  文字搜索目录:         ${search.searchRunDir}`);
    console.log(`  图搜目录:             ${img.imageRunDir}`);
    console.log(`  主关键词结果:         ${sel.outputPath}`);
    console.log(`  主关键词:             ${sel.mainKeyword}`);
    console.log(`  流程状态:             主关键词与首轮重复，跳过回接，回退图搜相似商品`);
    console.log(`  最终结果:             ${finalPath} (${fallbackProducts.length} 条商品)`);
    console.log("══════════════════════════════════════════════════════\n");
    return;
  }

  // ── 阶段 5：回接文字搜索
  const followUp = await stageFollowUpSearch(options, sel.mainKeyword);

  if (followUp.followUpValid) {
    const products = await readResultsFromPath(String(followUp.followUpValid.results_path ?? ""), options.contactLimit);
    const finalPath = await writeFinalResult(options.row, "keyword_search", sel.mainKeyword, products);
    console.log("\n╔════════════════════ 正式联调结果 ════════════════════╗");
    console.log(`  测试行号:             第 ${options.row} 行`);
    console.log(`  关键词目录:           ${keywordResult.runDir}`);
    console.log(`  文字搜索目录:         ${search.searchRunDir}`);
    console.log(`  图搜目录:             ${img.imageRunDir}`);
    console.log(`  主关键词结果:         ${sel.outputPath}`);
    console.log(`  回接搜索目录:         ${followUp.followUpRunDir}`);
    console.log(`  主关键词:             ${sel.mainKeyword}`);
    console.log(`  流程状态:             图搜主关键词回接命中`);
    console.log(`  最终结果:             ${finalPath} (${products.length} 条商品)`);
    console.log("══════════════════════════════════════════════════════\n");
    return;
  }

  const imageSearchReport = JSON.parse(await readFile(img.imageSearchReportPath, "utf8"));
  const fallbackProducts = Array.isArray(imageSearchReport.similar_products)
    ? applyMerchantLimit(imageSearchReport.similar_products, options.contactLimit).map(
      (item, i) => normalizeProductFromRaw(item as Record<string, unknown>, i + 1),
    )
    : [];
  const finalPath = await writeFinalResult(options.row, "image_search_fallback", sel.mainKeyword, fallbackProducts);

  console.log("\n╔════════════════════ 正式联调结果 ════════════════════╗");
  console.log(`  测试行号:             第 ${options.row} 行`);
  console.log(`  关键词目录:           ${keywordResult.runDir}`);
  console.log(`  文字搜索目录:         ${search.searchRunDir}`);
  console.log(`  图搜目录:             ${img.imageRunDir}`);
  console.log(`  主关键词结果:         ${sel.outputPath}`);
  console.log(`  主关键词:             ${sel.mainKeyword}`);
  console.log(`  流程状态:             图搜主关键词回接仍无效，最终回退图搜相似商品`);
  console.log(`  最终结果:             ${finalPath} (${fallbackProducts.length} 条商品)`);
  console.log("══════════════════════════════════════════════════════\n");
}

// 只在直接运行时执行 main()，import 时不自动触发
if (import.meta.url === pathToFileURL(process.argv[1]).href) {
  main().catch((error) => {
    console.error("\n❌ 正式联调执行失败:", error);
    process.exit(1);
  });
}
