/**
 * ============================================================
 * row-pipeline-sync.ts — 包装入口：row-pipeline + Supabase 同步
 * ============================================================
 * 用法：pnpm exec tsx automation/pipelines/row-pipeline-sync.ts --row=2
 *
 * 行为与 row-pipeline.ts 完全一致，
 * 但本地 output 仅作为临时中转目录：阶段完成后同步到 Supabase，
 * 每次运行前后都会自动清空，不保留截图和商家信息等本地产物。
 *
 * 断开方式：直接跑 row-pipeline.ts 即可，本文件是唯一碰 DB 的入口。
 * ============================================================
 */

import "dotenv/config";

import { mkdir, readFile, writeFile } from "node:fs/promises";
import path from "node:path";
import process from "node:process";
import { pathToFileURL } from "node:url";

import {
  parseArgs,
  applyMerchantLimit,
  stageKeywords,
  stageInitialSearch,
  stageImageSearch,
  readResultsFromPath,
  normalizeProductFromRaw,
  normalizeSearchPlanRoundFailureReason,
  type KeywordStageResult,
  type InitialSearchResult,
  type ImageSearchResult,
} from "./row-pipeline.ts";

import {
  upsertKeywordProcess,
  upsertSearchRound,
  upsertBestMatchImage,
  upsertExtendedBestMatches,
  upsertImageSearchResult,
  upsertFinalResult,
  upsertFinalResultProducts,
  upsertExtendedBestMatchRecords,
  updateSearchRoundStageDiagnostics,
} from "../internal/db.ts";

import { uploadScreenshot } from "../internal/storage.ts";
import {
  looksLikeGeneratedResourceTitle,
  sanitizeSearchResultTitle,
} from "../playwright/search-result-extractor.js";

import type { SearchPipelineConfig } from "../core/types.ts";
import { DEFAULT_SEARCH_PIPELINE_CONFIG } from "../core/types.ts";
import {
  annotateStageProducts,
  buildPlaceholderStageProduct,
  buildKeywordArtifactPipelineConfig,
  buildMerchantKey,
  buildSearchExecutionStages,
  hasDetectedPhoneContact,
  mergeStageProducts,
  type SearchExecutionStage,
  type StageResultProduct,
} from "./multi-stage-results.ts";
import {
  buildScoredRoundCandidates,
  computeSemanticCandidateHitRate,
  mergeScoredCandidates,
  type FixedSearchRoundStatus,
  type ScoredSearchCandidate,
} from "./fixed-search-runtime.ts";
import { judgeCandidatesWithLlm } from "./fixed-search-llm.ts";
import { resolveBigModelBatchModel } from "../standardization/bigmodel-batch-client.ts";
import {
  enrichSelectedOfferCandidates,
  type ContactEnrichmentResult,
  type EnrichedSelectedOffer,
} from "./offer-contact-enrichment.ts";

const AUTOMATION_DIR = path.resolve(import.meta.dirname!, "..");
const OUTPUT_ROOT = process.env.SEARCH_TASK_OUTPUT_ROOT
  ? path.resolve(process.env.SEARCH_TASK_OUTPUT_ROOT)
  : path.join(AUTOMATION_DIR, "output");
const ACTIVE_SEARCH_TASK_ID = String(process.env.SEARCH_TASK_ID ?? "").trim();
const uploadedFileCache = new Map<string, string>();
const SEARCH_CHILD_EVENT_PREFIX = "__SEARCH_EVENT__:";

type FinalResultPayload = {
  material_id: string;
  finished_at: string;
  source: string;
  main_keyword: string;
  product_count: number;
  products: unknown[];
};

type CompletedStageSnapshot = {
  label: string;
  source: string;
  mainKeyword: string;
  candidateCount: number;
  insertedCount: number;
  duplicateCount: number;
  keywordHitRate: number | null;
  stopReason: string;
};

type LatestKeywordStageContext = {
  search: InitialSearchResult;
  keywordResult: KeywordStageResult;
  stage: Extract<SearchExecutionStage, { kind: "keyword" }>;
};

type StageOutcome = {
  source: string;
  mainKeyword: string;
  products: ReturnType<typeof normalizeProductFromRaw>[];
  stageKeywordHitRate: number | null;
  stopPipeline: boolean;
  placeholderReason: string;
};

export function getKeywordStageSearchRoundIncrement(params: {
  keywordResult: { searchPlanRows?: unknown[] | null };
  initialRounds?: unknown[] | null;
}) {
  const searchPlanRowsCount = Array.isArray(params.keywordResult.searchPlanRows)
    ? params.keywordResult.searchPlanRows.length
    : 0;
  const initialRoundsCount = Array.isArray(params.initialRounds)
    ? params.initialRounds.length
    : 0;

  return Math.max(searchPlanRowsCount, initialRoundsCount, 1);
}

// ── sync helpers ─────────────────────────────────────────────

function syncLog(message: string) {
  console.log(`[sync] ${message}`);
}

function emitSearchProgress(params: {
  message: string;
  itemProgressPercent: number;
  softCapPercent?: number | null;
  stageStartedAt?: string;
}) {
  const payload = {
    type: "progress_update",
    taskId: ACTIVE_SEARCH_TASK_ID || "",
    materialId: process.env.SEARCH_TASK_CURRENT_MATERIAL_ID || "",
    materialName: process.env.SEARCH_TASK_CURRENT_MATERIAL_NAME || "",
    message: params.message,
    itemProgressPercent: params.itemProgressPercent,
    softCapPercent: params.softCapPercent ?? null,
    stageStartedAt: params.stageStartedAt || new Date().toISOString(),
  };
  console.log(`${SEARCH_CHILD_EVENT_PREFIX}${JSON.stringify(payload)}`);
}

async function trySync(label: string, fn: () => Promise<void>) {
  try {
    await fn();
    syncLog(`${label} ✓`);
  } catch (error) {
    const msg = error instanceof Error ? error.message : String(error);
    const stack = error instanceof Error ? error.stack : "";
    syncLog(`${label} ✗ (继续运行): ${msg}`);
    if (stack) {
      syncLog(`${label} Stack: ${stack}`);
    }
  }
}

async function criticalSync(label: string, fn: () => Promise<void>) {
  try {
    await fn();
    syncLog(`${label} ✓`);
  } catch (error) {
    const msg = error instanceof Error ? error.message : String(error);
    const stack = error instanceof Error ? error.stack : "";
    syncLog(`${label} ✗: ${msg}`);
    if (stack) {
      syncLog(`${label} Stack: ${stack}`);
    }
    throw error;
  }
}

function materialPrefix(materialId: string) {
  return `material-${materialId}`;
}

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

function normalizeOfferUrlForLookup(value: unknown) {
  const raw = normalizeText(value);
  if (!raw) return "";

  try {
    const parsed = new URL(raw.startsWith("//") ? `https:${raw}` : raw);
    const offerIdMatch = parsed.pathname.match(/\/offer\/(\d+)\.html/i);
    const offerId = offerIdMatch?.[1] || parsed.searchParams.get("offerId") || parsed.searchParams.get("id") || "";
    if (offerId) {
      return `https://detail.1688.com/offer/${offerId}.html`;
    }
    parsed.search = "";
    parsed.hash = "";
    return parsed.toString();
  } catch {
    return raw.split(/[?#]/, 1)[0] ?? raw;
  }
}

export function isUnsafeProductTitle(value: unknown) {
  const title = normalizeText(value);
  if (!title) return true;
  if (looksLikeGeneratedResourceTitle(title)) return true;
  if (/^(?:https?:)?\/\//i.test(title)) return true;
  if (/^data:[a-z]+\/[a-z0-9.+-]+;base64,/i.test(title)) return true;
  return false;
}

function safeProductTitleCandidate(value: unknown) {
  if (isUnsafeProductTitle(value)) return "";
  const title = sanitizeSearchResultTitle(value);
  return title && !isUnsafeProductTitle(title) ? title : "";
}

function buildTitleFallbackLookup(products: Record<string, unknown>[] = []) {
  const lookup = new Map<string, string>();

  for (const product of products) {
    const offerUrl = normalizeOfferUrlForLookup(product.offer_url ?? product.offerUrl ?? product.url);
    if (!offerUrl || lookup.has(offerUrl)) continue;

    const sourceProduct = product.source_product && typeof product.source_product === "object"
      ? product.source_product as Record<string, unknown>
      : {};
    const title = safeProductTitleCandidate(product.title)
      || safeProductTitleCandidate(product["title-text"])
      || safeProductTitleCandidate(sourceProduct.title);

    if (title) {
      lookup.set(offerUrl, title);
    }
  }

  return lookup;
}

export function resolveFinalResultProductTitle(
  product: Record<string, unknown>,
  fallbackProducts: Record<string, unknown>[] = [],
) {
  return resolveFinalResultProductTitleFromLookup(product, buildTitleFallbackLookup(fallbackProducts));
}

function resolveFinalResultProductTitleFromLookup(
  product: Record<string, unknown>,
  fallbackLookup: Map<string, string>,
) {
  const sourceProduct = product.source_product && typeof product.source_product === "object"
    ? product.source_product as Record<string, unknown>
    : {};
  const directTitle = safeProductTitleCandidate(product.title);
  if (directTitle) return directTitle;

  const inlineFallback = safeProductTitleCandidate(product["title-text"])
    || safeProductTitleCandidate(sourceProduct.title);
  if (inlineFallback) return inlineFallback;

  const offerUrl = normalizeOfferUrlForLookup(product.offer_url ?? product.offerUrl ?? product.url);
  if (!offerUrl) return "";

  return fallbackLookup.get(offerUrl) ?? "";
}

function pickFirstNormalizedText(...values: unknown[]) {
  for (const value of values) {
    const normalized = normalizeText(value);
    if (normalized) {
      return normalized;
    }
  }

  return "";
}

async function cleanupTemporaryOutput() {
  syncLog(`已跳过本地临时 output 清理，保留排查产物: ${OUTPUT_ROOT}`);
}

export function buildFinalResultPayload(
  materialId: string,
  source: string,
  mainKeyword: string,
  products: unknown[],
): FinalResultPayload {
  return {
    material_id: materialId,
    finished_at: new Date().toISOString(),
    source,
    main_keyword: mainKeyword,
    product_count: products.length,
    products,
  };
}

async function tryUpload(filePath: string, storagePath: string): Promise<string> {
  const cached = uploadedFileCache.get(filePath);
  if (cached) {
    return cached;
  }

  try {
    const url = await uploadScreenshot(filePath, storagePath);
    uploadedFileCache.set(filePath, url);
    return url;
  } catch (error) {
    const msg = error instanceof Error ? error.message : String(error);
    syncLog(`上传失败 (${storagePath}): ${msg}`);
    return "";
  }
}

// ── 阶段同步函数 ────────────────────────────────────────────

async function syncKeywordProcess(materialId: string, batchId: string, keywordResult: KeywordStageResult) {
  await trySync("keyword_process", async () => {
    const report = JSON.parse(await readFile(keywordResult.keywordProcessPath, "utf8"));
    await upsertKeywordProcess({
      batch_id: batchId,
      material_id: materialId,
      row_name: String(report.row_name ?? ""),
      row_spec: String(report.row_spec ?? ""),
      row_brand: String(report.row_brand ?? ""),
      llm_model: String(report.llm_model ?? ""),
      ai_keyword_analysis: report.ai_keyword_analysis ?? {},
      full_report: report,
    });
  });
}

async function syncInitialSearch(
  materialId: string,
  batchId: string,
  search: InitialSearchResult,
  options: {
    searchRoundOffset?: number;
    stage?: Extract<SearchExecutionStage, { kind: "keyword" }>;
  } = {},
) {
  const prefix = materialPrefix(materialId);
  const searchRoundOffset = options.searchRoundOffset ?? 0;
  const stage = options.stage;

  // 同步每个搜索轮次
  for (const round of search.initialRounds) {
    const persistedSearchRound = searchRoundOffset + Number(round.search_round ?? 1);
    await trySync(`search_round initial #${persistedSearchRound}`, async () => {
      // 上传截图
      let screenshotUrl = "";
      const screenshotPath = String(round.page_screenshot_path ?? "");
      if (screenshotPath) {
        screenshotUrl = await tryUpload(
          screenshotPath,
          `${prefix}/initial-search/round-${persistedSearchRound}${path.extname(screenshotPath)}`,
        );
      }

      // 读取该轮商品结果
      let results: unknown[] = [];
      const resultsPath = String(round.results_path ?? "");
      if (resultsPath) {
        try {
          results = JSON.parse(await readFile(resultsPath, "utf8"));
          if (!Array.isArray(results)) results = [];
        } catch {
          results = [];
        }
      }

      await upsertSearchRound({
        task_id: ACTIVE_SEARCH_TASK_ID || null,
        batch_id: batchId,
        material_id: materialId,
        search_round: persistedSearchRound,
        run_type: "initial",
        primary_keyword: String(round.primary_keyword ?? ""),
        search_strategy: String(round.search_strategy ?? ""),
        stage_order: stage?.stageOrder ?? null,
        stage_label: stage?.label ?? "",
        stage_kind: stage?.kind ?? "keyword",
        stage_main_keyword: String(round.executed_keyword ?? round.primary_keyword ?? ""),
        brand_keyword: String(round.brand_keyword ?? ""),
        brand_keyword_hit_rate: round.brand_keyword_hit_rate != null ? Number(round.brand_keyword_hit_rate) : null,
        is_valid_search: Boolean(round.is_valid_search),
        visual_confidence: round.visual_confidence != null ? Number(round.visual_confidence) : null,
        visual_reason: String(round.visual_reason ?? ""),
        stop_reason: String(round.stop_reason ?? ""),
        results_count: Number(round.results_count ?? results.length ?? 0),
        results,
        screenshot_url: screenshotUrl,
        keyword_hit_rate: round.keyword_hit_rate != null ? Number(round.keyword_hit_rate) : null,
        source_candidate_count: round.source_candidate_count != null ? Number(round.source_candidate_count) : null,
        qualified_result_count: null,
        rejected_no_phone_count: null,
        target_result_count: null,
        shortage_reason: String(round.shortage_reason ?? ""),
        require_phone_contact: round.require_phone_contact == null ? true : Boolean(round.require_phone_contact),
        contact_enrichment_attempted: Boolean(round.contact_enrichment_attempted),
        contact_enrichment_skip_reason: String(round.contact_enrichment_skip_reason ?? ""),
        keyword_hit_rate_source: String(round.keyword_hit_rate_source ?? "local"),
        keyword_hit_rate_reason: String(round.keyword_hit_rate_reason ?? ""),
        keyword_hit_rate_sample_count: round.keyword_hit_rate_sample_count != null ? Number(round.keyword_hit_rate_sample_count) : null,
      });
    });
  }

  // 同步 best match image
  const bmi = search.bestMatchImage as Record<string, unknown> | null;
  if (bmi?.image_path) {
    await trySync("best_match_image", async () => {
      const imagePath = String(bmi.image_path);
      const imageUrl = await tryUpload(
        imagePath,
        `${prefix}/best-match${path.extname(imagePath)}`,
      );
      await upsertBestMatchImage({
        batch_id: batchId,
        material_id: materialId,
        image_url: imageUrl,
        offer_url: String(bmi.offer_url ?? ""),
        title: String(bmi.title ?? ""),
        merchant_name: String(bmi.merchant_name ?? ""),
        wangwang_url: String(bmi.wangwang_url ?? ""),
        selected_from_round: bmi.selected_from_round != null ? Number(bmi.selected_from_round) : null,
        selected_from_keyword: String(bmi.selected_from_keyword ?? ""),
        method: String(bmi.method ?? ""),
        reason: String(bmi.reason ?? ""),
      });
    });
  }

  // 同步 extended best matches
  await trySync("extended_best_matches", async () => {
    const report = await readExtendedBestMatchReport(search.searchRunDir);
    if (!report) {
      syncLog("extended_best_matches 跳过：未生成 extended-best-matches-report.json");
      return;
    }

    await upsertExtendedBestMatches({ batch_id: batchId, material_id: materialId, report });
  });

  // 同步 extended_best_match_records（展开每条记录）
  await trySync("extended_best_match_records", async () => {
    const report = await readExtendedBestMatchReport(search.searchRunDir);
    if (!report) {
      syncLog("extended_best_match_records 跳过：未生成 extended-best-matches-report.json");
      return;
    }

    const generatedAt = String(report.generated_at ?? new Date().toISOString());
    const records = Array.isArray(report.records) ? report.records : [];
    const rows = [];
    for (const r of records as Record<string, unknown>[]) {
      const screenshotLocalPath = String(r.offer_screenshot_path ?? "");
      let offerScreenshotUrl = "";
      if (screenshotLocalPath) {
        offerScreenshotUrl = await tryUpload(
          screenshotLocalPath,
          `${prefix}/offer-screenshots/extended-rank-${r.rank}.jpg`,
        );
      }
      rows.push({
        batch_id: batchId,
        material_id: materialId,
        generated_at: generatedAt,
        rank: Number(r.rank ?? 0),
        title: String(r.title ?? ""),
        offer_url: String(r.offer_url ?? ""),
        image_url: String(r.image_url ?? ""),
        merchant_name: String(r.merchant_name ?? ""),
        merchant_url: String(r.merchant_url ?? ""),
        wangwang_url: String(r.wangwang_url ?? ""),
        offer_screenshot_url: offerScreenshotUrl,
        score: r.score != null ? Number(r.score) : null,
        selected_from_round: r.selected_from_round != null ? Number(r.selected_from_round) : null,
        selected_from_keyword: String(r.selected_from_keyword ?? ""),
        detected_contacts: Array.isArray(r.detected_contacts) ? r.detected_contacts : [],
        contact_page_url: String(r.contact_page_url ?? ""),
      });
    }
    await upsertExtendedBestMatchRecords(materialId, rows, batchId);
  });
}

async function syncImageSearch(materialId: string, batchId: string, img: ImageSearchResult) {
  const prefix = materialPrefix(materialId);

  await trySync("image_search_result", async () => {
    const report = JSON.parse(await readFile(img.imageSearchReportPath, "utf8"));
    const imageSearch = report.image_search ?? {};

    let screenshotUrl = "";
    const screenshotPath = String(imageSearch.screenshot_path ?? "");
    if (screenshotPath) {
      screenshotUrl = await tryUpload(
        screenshotPath,
        `${prefix}/image-search/page${path.extname(screenshotPath)}`,
      );
    }

    await upsertImageSearchResult({
      batch_id: batchId,
      material_id: materialId,
      is_valid_image_search: img.isValidImageSearch,
      screenshot_url: screenshotUrl,
      top_titles: Array.isArray(imageSearch.top_titles) ? imageSearch.top_titles : [],
      similar_products: Array.isArray(report.similar_products) ? report.similar_products : [],
      text_rule: imageSearch.text_rule ?? {},
      image_similarity: imageSearch.image_similarity ?? {},
      full_report: report,
    });
  });
}

export async function syncFinalResult(
  materialId: string,
  batchId: string,
  data: FinalResultPayload,
  productRows?: Record<string, unknown>[],
  titleFallbackProducts: Record<string, unknown>[] = [],
) {
  await criticalSync("final_result", async () => {
    await upsertFinalResult({
      task_id: ACTIVE_SEARCH_TASK_ID || null,
      batch_id: batchId,
      material_id: materialId,
      source: String(data.source ?? ""),
      main_keyword: String(data.main_keyword ?? ""),
      product_count: Number(data.product_count ?? 0),
      products: Array.isArray(data.products) ? data.products : [],
      finished_at: String(data.finished_at ?? new Date().toISOString()),
    });
  });

  // 同步 final_result_products（展开每条商品）
  await criticalSync("final_result_products", async () => {
    const finishedAt = String(data.finished_at ?? new Date().toISOString());
    const products = Array.isArray(productRows)
      ? productRows
      : (Array.isArray(data.products) ? data.products : []);
    const titleFallbackLookup = buildTitleFallbackLookup([
      ...titleFallbackProducts,
      ...(Array.isArray(data.products) ? data.products as Record<string, unknown>[] : []),
      ...(Array.isArray(productRows) ? productRows : []),
    ]);
    const rows = [];
    for (const [index, product] of (products as Record<string, unknown>[]).entries()) {
      const rank = Number(product.stage_rank ?? product.index ?? index + 1);
      const source = String(product.source ?? data.source ?? "");
      const mainKeyword = String(product.main_keyword ?? data.main_keyword ?? "");
      const screenshotLocalPath = String(product.offer_screenshot_path ?? "");
      const screenshotExt = path.extname(screenshotLocalPath) || ".jpg";
      const stageOrder = Number(product.stage_order ?? 0);
      let offerScreenshotUrl = "";
      if (screenshotLocalPath) {
        offerScreenshotUrl = await tryUpload(
          screenshotLocalPath,
          `${materialPrefix(materialId)}/offer-screenshots/${stageOrder > 0 ? `stage-${stageOrder}-rank-${rank}` : `final-rank-${rank}`}${screenshotExt}`,
        );
      }
      rows.push({
        task_id: ACTIVE_SEARCH_TASK_ID || null,
        batch_id: batchId,
        material_id: materialId,
        source,
        main_keyword: mainKeyword,
        stage_order: stageOrder > 0 ? stageOrder : null,
        stage_label: String(product.stage_label ?? ""),
        stage_kind: String(product.stage_kind ?? ""),
        stage_main_keyword: String(product.stage_main_keyword ?? mainKeyword),
        stage_keyword_hit_rate: product.stage_keyword_hit_rate != null ? Number(product.stage_keyword_hit_rate) : null,
        is_placeholder: Boolean(product.is_placeholder),
        placeholder_reason: String(product.placeholder_reason ?? ""),
        finished_at: finishedAt,
        rank,
        title: resolveFinalResultProductTitleFromLookup(product, titleFallbackLookup),
        offer_url: String(product.offer_url ?? ""),
        image_url: String(product.image_url ?? ""),
        merchant_name: String(product.merchant_name ?? ""),
        merchant_url: String(product.merchant_url ?? ""),
        wangwang_url: String(product.wangwang_url ?? ""),
        offer_screenshot_url: offerScreenshotUrl,
        contact_page_url: String(product.contact_page_url ?? ""),
        detected_contacts: Array.isArray(product.detected_contacts) ? product.detected_contacts : [],
        score: product.score != null ? Number(product.score) : null,
        is_match: product.is_match == null ? null : Boolean(product.is_match),
        reason: String(product.reason ?? ""),
        has_phone_contact: Boolean(product.has_phone_contact),
        qualification_status: String(product.qualification_status ?? (product.is_placeholder ? "placeholder" : "qualified")),
        contact_collection_reason: String(product.contact_collection_reason ?? ""),
      });
    }
    await upsertFinalResultProducts(materialId, rows, batchId);
  });
}

// ── main ─────────────────────────────────────────────────────

function parsePipelineConfig(): SearchPipelineConfig {
  const raw = process.env.SEARCH_PIPELINE_CONFIG;
  if (!raw) return DEFAULT_SEARCH_PIPELINE_CONFIG;
  try {
    return JSON.parse(raw) as SearchPipelineConfig;
  } catch {
    syncLog(`SEARCH_PIPELINE_CONFIG 解析失败，使用默认配置`);
    return DEFAULT_SEARCH_PIPELINE_CONFIG;
  }
}

function buildFallbackMainKeyword(search: InitialSearchResult) {
  return String(
    search.bestMatchImage?.selected_from_keyword
      ?? search.validRound?.primary_keyword
      ?? search.initialRounds[0]?.primary_keyword
      ?? "",
  );
}

async function readExtendedBestMatchReport(searchRunDir: string): Promise<Record<string, unknown> | null> {
  const extendedReportPath = path.join(searchRunDir, "extended-best-matches", "extended-best-matches-report.json");
  try {
    return JSON.parse(await readFile(extendedReportPath, "utf8")) as Record<string, unknown>;
  } catch {
    return null;
  }
}

async function readExtendedBestMatchProducts(searchRunDir: string, limit = 0) {
  const extendedReport = await readExtendedBestMatchReport(searchRunDir);
  if (!extendedReport) {
    return [];
  }

  return applyMerchantLimit(
    Array.isArray(extendedReport.records) ? extendedReport.records : [],
    limit,
  ).map((item, index) => normalizeProductFromRaw(item as Record<string, unknown>, index + 1));
}

async function readImageSearchProducts(imageSearchReportPath: string, limit = 0) {
  try {
    const imageSearchReport = JSON.parse(await readFile(imageSearchReportPath, "utf8"));
    return applyMerchantLimit(
      Array.isArray(imageSearchReport.similar_products) ? imageSearchReport.similar_products : [],
      limit,
    ).map((item, index) => normalizeProductFromRaw(item as Record<string, unknown>, index + 1));
  } catch {
    return [];
  }
}

type FixedRuntimeSelectionResult = {
  source: string;
  mainKeyword: string;
  candidateCount: number;
  duplicateCount: number;
  hitRate: number | null;
  llmMatchedCount: number;
  attemptedContactCount: number;
  qualifiedContactCount: number;
  targetResultCount: number | null;
  shortageReason: string;
  artifacts: {
    llmMatchResultsPath: string;
    contactEnrichmentResultsPath: string;
    finalSelectedProductsPath: string;
  };
  finalProducts: Array<Record<string, unknown>>;
};

function buildFixedRuntimeMainKeyword(roundStatuses: FixedSearchRoundStatus[]) {
  return roundStatuses
    .filter((status) => status.status === "success")
    .map((status) => status.query)
    .filter(Boolean)
    .join(" | ");
}

function formatNullableHitRate(value: number | null) {
  return value == null ? "-" : `${(value * 100).toFixed(1)}%`;
}

async function loadFixedSearchRoundStatuses(keywordResult: KeywordStageResult): Promise<FixedSearchRoundStatus[]> {
  if (Array.isArray((keywordResult as { searchRoundStatuses?: unknown[] }).searchRoundStatuses)) {
    return ((keywordResult as { searchRoundStatuses?: unknown[] }).searchRoundStatuses ?? [])
      .filter((item): item is FixedSearchRoundStatus => Boolean(item && typeof item === "object"));
  }

  const searchRoundStatusPath = String((keywordResult as { searchRoundStatusPath?: unknown }).searchRoundStatusPath ?? "");
  if (!searchRoundStatusPath) {
    return [];
  }

  try {
    const parsed = JSON.parse(await readFile(searchRoundStatusPath, "utf8"));
    return Array.isArray(parsed)
      ? parsed.filter((item): item is FixedSearchRoundStatus => Boolean(item && typeof item === "object"))
      : [];
  } catch {
    return [];
  }
}

async function readRawRoundProducts(round: Record<string, unknown> | null | undefined) {
  const resultsPath = String(round?.results_path ?? "");
  if (resultsPath) {
    try {
      const parsed = JSON.parse(await readFile(resultsPath, "utf8"));
      return Array.isArray(parsed) ? parsed as Record<string, unknown>[] : [];
    } catch {
      return [];
    }
  }

  return Array.isArray(round?.results) ? round?.results as Record<string, unknown>[] : [];
}

async function readSearchRoundTitleFallbackProducts(rounds: Record<string, unknown>[] = []) {
  const products: Record<string, unknown>[] = [];
  for (const round of rounds) {
    products.push(...await readRawRoundProducts(round));
  }
  return products;
}

export function buildFixedRuntimeLlmContext(keywordResult: KeywordStageResult, roundStatuses: FixedSearchRoundStatus[]) {
  const targetRow = keywordResult.targetRow as Record<string, unknown> | undefined;
  return {
    primary_search_term: pickFirstNormalizedText(
      keywordResult.aiKeywordAnalysis?.suggested_keyword,
      buildFixedRuntimeMainKeyword(roundStatuses).split(" | ")[0],
    ),
    standardized_name: pickFirstNormalizedText(targetRow?.名称),
    brand: pickFirstNormalizedText(keywordResult.analysis?.brand_term, targetRow?.品牌),
    standardized_spec: pickFirstNormalizedText(keywordResult.analysis?.simplified_spec, targetRow?.规格型号),
    category: pickFirstNormalizedText(keywordResult.analysis?.material),
    ai_reasoning: pickFirstNormalizedText(keywordResult.aiKeywordAnalysis?.structure_summary),
    product_type: pickFirstNormalizedText(keywordResult.aiKeywordAnalysis?.product_type),
  };
}

async function resolveFixedRuntimeSelection(params: {
  materialId: string;
  batchId: string;
  search: InitialSearchResult;
  keywordResult: KeywordStageResult;
  stage: Extract<SearchExecutionStage, { kind: "keyword" }>;
  contactLimit: number;
  headless: boolean;
}) : Promise<FixedRuntimeSelectionResult> {
  const roundStatuses = await loadFixedSearchRoundStatuses(params.keywordResult);
  const successfulRoundStatuses = roundStatuses.filter((status) => status.status === "success");
  const roundMap = new Map(
    params.search.initialRounds.map((round) => [Number(round.search_round ?? 0), round as Record<string, unknown>]),
  );
  const roundCandidates: ScoredSearchCandidate[] = [];

  for (const roundStatus of successfulRoundStatuses) {
    const rawProducts = await readRawRoundProducts(roundMap.get(roundStatus.round));
    roundCandidates.push(
      ...buildScoredRoundCandidates({
        round: roundStatus,
        products: rawProducts,
      }),
    );
  }

  const mergedCandidates = mergeScoredCandidates(roundCandidates);
  emitSearchProgress({
    message: `已完成搜索结果汇总，正在准备大模型商品匹配判断...`,
    itemProgressPercent: 35,
    softCapPercent: 45,
  });
  const llmStageStartedAt = new Date().toISOString();
  emitSearchProgress({
    message: "正在调用大模型判断商品匹配，请稍候...",
    itemProgressPercent: 45,
    softCapPercent: 75,
    stageStartedAt: llmStageStartedAt,
  });
  const fixedSearchBatchModel = resolveBigModelBatchModel();
  const llmMatchedCandidates = await judgeCandidatesWithLlm({
    context: buildFixedRuntimeLlmContext(params.keywordResult, roundStatuses),
    candidates: mergedCandidates,
    taskId: ACTIVE_SEARCH_TASK_ID || "",
    materialId: params.materialId,
    outputDir: params.search.searchRunDir,
    onBatchComplete: async ({ completedBatches, totalBatches, batchId, batchStatus }) => {
      const progress = 55 + Math.round((completedBatches / Math.max(1, totalBatches)) * 25);
      emitSearchProgress({
        message: `正在等待 ${fixedSearchBatchModel} 离线 Batch 商品匹配结果: ${completedBatches}/${totalBatches}${batchStatus ? ` (${batchStatus})` : ""}${batchId ? ` · ${batchId}` : ""}`,
        itemProgressPercent: progress,
        softCapPercent: 82,
        stageStartedAt: llmStageStartedAt,
      });
    },
  });
  emitSearchProgress({
    message: "大模型商品匹配判断完成，正在整理候选商家...",
    itemProgressPercent: 82,
    softCapPercent: 85,
  });
  const llmMatchResultsPath = path.join(params.search.searchRunDir, "llm-match-results.json");
  await writeFile(
    llmMatchResultsPath,
    JSON.stringify(llmMatchedCandidates.map((candidate) => ({
      input: candidate.input,
      "title-text": candidate["title-text"],
      url: candidate.url,
      score: candidate.score,
      is_match: candidate.is_match,
      reason: candidate.reason,
      judge_status: candidate.judge_status,
      batch_id: candidate.batch_id,
      batch_custom_id: candidate.batch_custom_id,
      search_round: candidate.search_round,
      round_label: candidate.round_label,
      source_product: candidate.source_product,
      merchant_name: candidate.source_product.merchant_name,
      merchant_url: candidate.source_product.merchant_url,
      image_url: candidate.source_product.image_url,
      wangwang_url: candidate.source_product.wangwang_url,
      contact_page_url: candidate.source_product.contact_page_url,
    })), null, 2),
    "utf8",
  );
  const hitRateStats = computeSemanticCandidateHitRate(llmMatchedCandidates);
  const hitRateReason = [
    `matched=${hitRateStats.matched_count}`,
    `judged=${hitRateStats.judged_count}`,
    `unjudged=${hitRateStats.unjudged_count}`,
    `errors=${hitRateStats.error_count}`,
  ].join(" ");

  const matchedCandidates = llmMatchedCandidates
    .filter((candidate) => candidate.judge_status === "judged" && candidate.is_match === true)
    .sort((left, right) => (
      right.score - left.score
      || left.search_round - right.search_round
      || left.url.localeCompare(right.url, "zh-CN")
    ));
  const judgedCandidateCount = llmMatchedCandidates.filter((candidate) => candidate.judge_status === "judged").length;
  const llmErrorCount = llmMatchedCandidates.filter((candidate) => candidate.judge_status === "llm_error").length;
  const llmBatchFailed = mergedCandidates.length > 0 && judgedCandidateCount === 0 && llmErrorCount > 0;
  const screenshotDir = path.join(params.search.searchRunDir, "final-selected-offers");
  await mkdir(screenshotDir, { recursive: true });
  const contactAuditPath = path.join(params.search.searchRunDir, "contact-enrichment-results.json");
  const targetQualifiedCount = Number.isInteger(params.contactLimit) && params.contactLimit > 0
    ? params.contactLimit
    : undefined;
  const contactEnrichmentResult: ContactEnrichmentResult = matchedCandidates.length > 0
    ? await (async () => {
      emitSearchProgress({
        message: "正在按大模型匹配顺序补齐商家电话/手机...",
        itemProgressPercent: 85,
        softCapPercent: 95,
      });
      return enrichSelectedOfferCandidates({
        candidates: matchedCandidates,
        screenshotDir,
        headless: params.headless,
        targetQualifiedCount,
        auditPath: contactAuditPath,
        onCandidateAttempt: ({ attemptedCount, qualifiedCount, targetQualifiedCount, status }) => {
          const targetText = targetQualifiedCount ?? matchedCandidates.length;
          const progress = Math.min(95, 85 + Math.floor((qualifiedCount / Math.max(1, targetText)) * 10));
          const statusText = status === "started"
            ? "正在抓取"
            : status === "qualified"
              ? "已获得有效商家"
              : status === "rejected_contact_error"
                ? "当前候选抓取失败，继续补位"
                : "当前候选无完整电话/手机，继续补位";
          emitSearchProgress({
            message: `${statusText}: 已尝试 ${attemptedCount} 个 LLM 匹配候选，已获得 ${qualifiedCount}/${targetText} 个有效商家`,
            itemProgressPercent: progress,
            softCapPercent: 95,
          });
        },
      });
    })()
    : {
      enriched: [],
      attempts: [],
      attemptedCount: 0,
      attemptedCandidateCount: 0,
      qualifiedCount: 0,
      targetQualifiedCount: targetQualifiedCount ?? null,
      initialWindowSize: 0,
      shortageReason: targetQualifiedCount
        ? (mergedCandidates.length === 0
            ? "no_search_candidates"
            : llmBatchFailed
              ? "llm_batch_failed"
              : "matched_candidates_exhausted_or_valid_phone_insufficient")
        : "",
    };
  if (matchedCandidates.length === 0) {
    await writeFile(contactAuditPath, JSON.stringify(contactEnrichmentResult, null, 2), "utf8");
  }
  const enrichedSelectedProducts: EnrichedSelectedOffer[] = contactEnrichmentResult.enriched;
  const shortageReason = contactEnrichmentResult.shortageReason
    || (targetQualifiedCount != null && enrichedSelectedProducts.length < targetQualifiedCount
      ? (mergedCandidates.length === 0
          ? "no_search_candidates"
          : llmBatchFailed
            ? "llm_batch_failed"
            : "matched_candidates_exhausted_or_valid_phone_insufficient")
      : "");
  if (targetQualifiedCount != null && enrichedSelectedProducts.length < targetQualifiedCount) {
    syncLog(
      `固定运行时电话补齐不足: 目标=${targetQualifiedCount} 合格=${enrichedSelectedProducts.length} LLM匹配候选=${matchedCandidates.length}，原因=${shortageReason || "unknown"}`,
    );
  }
  await trySync("fixed_runtime_stage_diagnostics", async () => {
    await updateSearchRoundStageDiagnostics({
      batchId: params.batchId,
      materialId: params.materialId,
      stageOrder: params.stage.stageOrder,
      taskId: ACTIVE_SEARCH_TASK_ID || null,
      sourceCandidateCount: mergedCandidates.length,
      keywordHitRate: hitRateStats.hit_rate,
      keywordHitRateSource: "llm_semantic_match",
      keywordHitRateReason: hitRateReason,
      keywordHitRateSampleCount: hitRateStats.judged_count,
      targetResultCount: targetQualifiedCount ?? null,
      shortageReason,
      contactEnrichmentAttempted: matchedCandidates.length > 0,
      contactEnrichmentSkipReason: matchedCandidates.length > 0 ? "" : (shortageReason || "llm_no_matches"),
    });
  });
  emitSearchProgress({
    message: "商家电话补齐完成，正在写入最终搜索结果...",
    itemProgressPercent: 96,
    softCapPercent: 98,
  });

  const finalProducts = enrichedSelectedProducts.map((product, index) => ({
    ...product,
    index: index + 1,
    source: "fixed_runtime_search",
    main_keyword: product.input,
    stage_order: params.stage.stageOrder,
    stage_label: params.stage.label,
    stage_kind: params.stage.kind,
    stage_main_keyword: product.input,
    stage_keyword_hit_rate: hitRateStats.hit_rate,
    stage_rank: index + 1,
    is_placeholder: false,
    placeholder_reason: "",
  }));
  const finalSelectedProductsPath = path.join(params.search.searchRunDir, "final-selected-products.json");
  await writeFile(finalSelectedProductsPath, JSON.stringify(finalProducts, null, 2), "utf8");

  return {
    source: "fixed_runtime_search",
    mainKeyword: buildFixedRuntimeMainKeyword(roundStatuses),
    candidateCount: mergedCandidates.length,
    duplicateCount: Math.max(0, roundCandidates.length - mergedCandidates.length),
    hitRate: hitRateStats.hit_rate,
    llmMatchedCount: matchedCandidates.length,
    attemptedContactCount: contactEnrichmentResult.attemptedCount,
    qualifiedContactCount: enrichedSelectedProducts.length,
    targetResultCount: targetQualifiedCount ?? null,
    shortageReason,
    artifacts: {
      llmMatchResultsPath,
      contactEnrichmentResultsPath: contactAuditPath,
      finalSelectedProductsPath,
    },
    finalProducts,
  };
}

async function resolveKeywordStageProducts(search: InitialSearchResult, limit: number): Promise<StageOutcome> {
  // 聚合所有轮次的结果（每轮都已持久化），按商家去重累计
  const allRoundsWithResults = search.initialRounds.filter(
    (round) => String(round.results_path ?? "").length > 0,
  );
  const preferredRound = pickPreferredStageRound(search);
  const mainKeyword = String(
    preferredRound?.executed_keyword
      ?? preferredRound?.primary_keyword
      ?? buildFallbackMainKeyword(search),
  );
  const stageKeywordHitRate = normalizeNullableNumber(preferredRound?.keyword_hit_rate);
  const stopPipeline = Boolean(preferredRound?.brand_zero_hit_stop);

  if (stopPipeline) {
    return {
      source: "keyword_search",
      mainKeyword,
      products: [],
      stageKeywordHitRate,
      stopPipeline: true,
      placeholderReason: "brand_zero_hit_stop",
    };
  }

  if (allRoundsWithResults.length > 0) {
    const seenKeys = new Set<string>();
    const merged: ReturnType<typeof normalizeProductFromRaw>[] = [];

    for (const round of allRoundsWithResults) {
      const roundProducts = await readResultsFromPath(String(round.results_path ?? ""), limit);
      for (const product of roundProducts) {
        const key = buildMerchantKey(product);
        if (key && seenKeys.has(key)) continue;
        if (key) seenKeys.add(key);
        merged.push(product);
      }
    }

    return {
      source: "keyword_search",
      mainKeyword,
      products: merged,
      stageKeywordHitRate,
      stopPipeline: false,
      placeholderReason: "",
    };
  }

  // 兜底：所有轮次 results_path 为空时，从 search_rounds 的 results JSON 字段提取产品
  const roundsWithInlineResults = search.initialRounds.filter((round) => {
    const results = round.results as unknown[];
    return Array.isArray(results) && results.length > 0;
  });
  if (roundsWithInlineResults.length > 0) {
    syncLog(`resolveKeywordStageProducts: 所有 results_path 为空，从 ${roundsWithInlineResults.length} 个轮次的内联 results 字段恢复`);
    const seenKeys = new Set<string>();
    const merged: ReturnType<typeof normalizeProductFromRaw>[] = [];
    for (const round of roundsWithInlineResults) {
      const rawProducts = applyMerchantLimit(round.results as Record<string, unknown>[], limit);
      for (const [index, item] of rawProducts.entries()) {
        const product = normalizeProductFromRaw(item, index + 1);
        const key = buildMerchantKey(product);
        if (key && seenKeys.has(key)) continue;
        if (key) seenKeys.add(key);
        merged.push(product);
      }
    }
    if (merged.length > 0) {
      return {
        source: "keyword_search_inline_fallback",
        mainKeyword,
        products: merged,
        stageKeywordHitRate,
        stopPipeline: false,
        placeholderReason: "",
      };
    }
  }

  const extendedBestMatchProducts = await readExtendedBestMatchProducts(search.searchRunDir, limit);
  if (extendedBestMatchProducts.length > 0) {
    return {
      source: "extended_best_match_fallback",
      mainKeyword,
      products: extendedBestMatchProducts,
      stageKeywordHitRate,
      stopPipeline: false,
      placeholderReason: "",
    };
  }

  const placeholderReason = resolveKeywordStagePlaceholderReason(search);
  if (placeholderReason) {
    return {
      source: "keyword_search",
      mainKeyword,
      products: [],
      stageKeywordHitRate,
      stopPipeline: false,
      placeholderReason,
    };
  }

  return {
    source: "extended_best_match_fallback",
    mainKeyword,
    products: [],
    stageKeywordHitRate,
    stopPipeline: false,
    placeholderReason: "",
  };
}

async function resolveImageStageProducts(
  search: Pick<InitialSearchResult, "searchRunDir" | "bestMatchImage" | "validRound" | "initialRounds">,
  img: ImageSearchResult,
  limit: number,
): Promise<StageOutcome> {
  const mainKeyword = buildFallbackMainKeyword(search);
  if (img.isValidImageSearch) {
    return {
      source: "image_search",
      mainKeyword,
      products: await readImageSearchProducts(img.imageSearchReportPath, limit),
      stageKeywordHitRate: null,
      stopPipeline: false,
      placeholderReason: "",
    };
  }

  return {
    source: "extended_best_match_fallback",
    mainKeyword,
    products: await readExtendedBestMatchProducts(search.searchRunDir, limit),
    stageKeywordHitRate: null,
    stopPipeline: false,
    placeholderReason: "",
  };
}

export async function resolveImageStageOutcome(params: {
  search: Pick<InitialSearchResult, "searchRunDir" | "bestMatchImage" | "validRound" | "initialRounds">;
  imageResult: ImageSearchResult | null;
  limit: number;
}): Promise<StageOutcome> {
  if (!params.imageResult) {
    return {
      source: "extended_best_match_fallback",
      mainKeyword: buildFallbackMainKeyword(params.search),
      products: await readExtendedBestMatchProducts(params.search.searchRunDir, params.limit),
      stageKeywordHitRate: null,
      stopPipeline: false,
      placeholderReason: "",
    };
  }

  return resolveImageStageProducts(params.search, params.imageResult, params.limit);
}

function buildAggregateFinalResultPayload(
  materialId: string,
  accumulatedProducts: StageResultProduct[],
  completedStages: CompletedStageSnapshot[],
): FinalResultPayload {
  return buildFinalResultPayload(
    materialId,
    completedStages.length > 1 ? "multi_stage_search" : (completedStages[0]?.source ?? "multi_stage_search"),
    completedStages.map((stage) => stage.mainKeyword).filter(Boolean).join(" | "),
    accumulatedProducts,
  );
}

function normalizeNullableNumber(value: unknown): number | null {
  if (value == null || value === "") {
    return null;
  }

  const numeric = Number(value);
  return Number.isFinite(numeric) ? numeric : null;
}

function pickPreferredStageRound(search: Pick<InitialSearchResult, "initialRounds" | "validRound">) {
  if (search.initialRounds.length === 0) {
    return null;
  }

  return search.initialRounds.find((round) => Boolean(round.brand_zero_hit_stop))
    ?? search.validRound
    ?? search.initialRounds.at(-1)
    ?? search.initialRounds[0]
    ?? null;
}

export function resolveKeywordStagePlaceholderReason(search: Pick<InitialSearchResult, "initialRounds" | "validRound">) {
  const preferredRound = pickPreferredStageRound(search);
  const roundFailureReason = normalizeSearchPlanRoundFailureReason(
    preferredRound?.round_failure_reason ?? preferredRound?.contact_enrichment_skip_reason,
  );

  if (roundFailureReason === "captcha_intercept") {
    return roundFailureReason;
  }

  return "";
}

async function syncAccumulatedStageResult(params: {
  materialId: string;
  batchId: string;
  stage: SearchExecutionStage;
  source: string;
  mainKeyword: string;
  products: ReturnType<typeof normalizeProductFromRaw>[];
  stageKeywordHitRate: number | null;
  placeholderReason?: string;
  accumulatedProducts: StageResultProduct[];
  persistedStageProducts: StageResultProduct[];
  completedStages: CompletedStageSnapshot[];
}) {
  const qualifiedProducts = params.products.filter((product) => (
    product.has_phone_contact || hasDetectedPhoneContact(product.detected_contacts)
  ));
  const rejectedNoPhoneProducts = params.products.filter((product) => !(
    product.has_phone_contact || hasDetectedPhoneContact(product.detected_contacts)
  ));

  if (rejectedNoPhoneProducts.length > 0) {
    syncLog(`阶段 ${params.stage.stageOrder} ${params.stage.label}: ${rejectedNoPhoneProducts.length} 个商家无号码（仅审计保留）`);
  }

  const qualifiedStageProducts = annotateStageProducts(qualifiedProducts, {
    source: params.source,
    mainKeyword: params.mainKeyword,
    stageLabel: params.stage.label,
    stageOrder: params.stage.stageOrder,
    stageKind: params.stage.kind,
    stageKeywordHitRate: params.stageKeywordHitRate,
  });
  const rejectedStageProducts = annotateStageProducts(
    rejectedNoPhoneProducts.map((product) => ({
      ...product,
      has_phone_contact: false,
      qualification_status: String(product.qualification_status || "rejected_no_phone"),
    })),
    {
      source: params.source,
      mainKeyword: params.mainKeyword,
      stageLabel: params.stage.label,
      stageOrder: params.stage.stageOrder,
      stageKind: params.stage.kind,
      stageKeywordHitRate: params.stageKeywordHitRate,
    },
  );

  let nextAccumulatedProducts = params.accumulatedProducts;
  let nextPersistedStageProducts = params.persistedStageProducts;
  let insertedCount = 0;
  let duplicateCount = 0;

  if (params.placeholderReason) {
    nextPersistedStageProducts = [
      ...params.persistedStageProducts,
      buildPlaceholderStageProduct({
        source: params.source,
        mainKeyword: params.mainKeyword,
        stageLabel: params.stage.label,
        stageOrder: params.stage.stageOrder,
        stageKind: params.stage.kind,
        stageKeywordHitRate: params.stageKeywordHitRate,
        placeholderReason: params.placeholderReason,
      }),
    ];
  } else {
    const mergeResult = mergeStageProducts(params.accumulatedProducts, qualifiedStageProducts);
    nextAccumulatedProducts = mergeResult.merged;
    nextPersistedStageProducts = [
      ...params.persistedStageProducts,
      ...mergeResult.inserted,
      ...rejectedStageProducts,
    ];
    insertedCount = mergeResult.insertedCount;
    duplicateCount = mergeResult.duplicateCount;
  }

  const completedStage: CompletedStageSnapshot = {
    label: params.stage.label,
    source: params.source,
    mainKeyword: params.mainKeyword,
    candidateCount: params.products.length,
    insertedCount,
    duplicateCount,
    keywordHitRate: params.stageKeywordHitRate,
    stopReason: params.placeholderReason ?? "",
  };
  const completedStages = [...params.completedStages, completedStage];
  await syncFinalResult(
    params.materialId,
    params.batchId,
    buildAggregateFinalResultPayload(params.materialId, nextAccumulatedProducts, completedStages),
    nextPersistedStageProducts,
  );
  syncLog(
    `阶段 ${params.stage.stageOrder} ${params.stage.label}: 候选=${params.products.length} 有效=${qualifiedStageProducts.length} 过滤=${rejectedStageProducts.length} 新增=${insertedCount} 重复=${duplicateCount} 命中率=${params.stageKeywordHitRate != null ? `${(params.stageKeywordHitRate * 100).toFixed(1)}%` : "-"}${params.placeholderReason ? ` | 停止=${params.placeholderReason}` : ""} 累计=${nextAccumulatedProducts.length}`,
  );

  return {
    accumulatedProducts: nextAccumulatedProducts,
    persistedStageProducts: nextPersistedStageProducts,
    completedStages,
  };
}

async function main() {
  const options = parseArgs(process.argv.slice(2));
  const pipelineConfig = parsePipelineConfig();
  const executionStages = buildSearchExecutionStages(pipelineConfig);
  const keywordArtifactConfig = buildKeywordArtifactPipelineConfig(pipelineConfig);
  syncLog(`开始同步模式 material_id=${options.materialId} batch_id=${options.batchId}`);
  syncLog(`管道配置: keywordSearch=${pipelineConfig.keywordSearchEnabled} imageSearch=${pipelineConfig.imageSearchEnabled} combinations=${pipelineConfig.keywordCombinations.length} aiKeyword=${pipelineConfig.includeAiKeyword}`);
  await cleanupTemporaryOutput();
  uploadedFileCache.clear();

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

  if (executionStages.length === 0) {
    await syncFinalResult(options.materialId, options.batchId, buildFinalResultPayload(options.materialId, "multi_stage_search", "", []));
    console.log("\n╔════════════════════ 正式联调结果（sync）════════════════════╗");
    console.log(`  物料ID:               ${options.materialId}`);
    console.log(`  流程状态:             未启用任何搜索阶段`);
    console.log(`  最终结果:             已同步 0 条商品`);
    console.log(`  Supabase:             已同步`);
    console.log("═══════════════════════════════════════════════════════════\n");
    return;
  }

  let searchRoundOffset = 0;
  let accumulatedProducts: StageResultProduct[] = [];
  let persistedStageProducts: StageResultProduct[] = [];
  let completedStages: CompletedStageSnapshot[] = [];
  let latestKeywordStage: LatestKeywordStageContext | null = null;

  for (const stage of executionStages) {
    syncLog(`开始阶段 ${stage.stageOrder}/${executionStages.length}: ${stage.label}`);

    if (stage.kind === "keyword") {
      const stageKeywordResult = await stageKeywords(options.materialId, options.batchId || undefined, stage.pipelineConfig);
      const search = await stageInitialSearch(options, stageKeywordResult, stage);
      await syncInitialSearch(options.materialId, options.batchId, search, { searchRoundOffset, stage });
      searchRoundOffset += getKeywordStageSearchRoundIncrement({
        keywordResult: stageKeywordResult,
        initialRounds: search.initialRounds,
      });

      if (stage.mode === "fixed_runtime") {
        const fixedRuntimeResult = await resolveFixedRuntimeSelection({
          materialId: options.materialId,
          batchId: options.batchId,
          search,
          keywordResult: stageKeywordResult,
          stage,
          contactLimit: options.contactLimit,
          headless: options.headless,
        });
        accumulatedProducts = fixedRuntimeResult.finalProducts as unknown as StageResultProduct[];
        persistedStageProducts = [...accumulatedProducts];
        completedStages = [
          ...completedStages,
          {
            label: stage.label,
            source: fixedRuntimeResult.source,
            mainKeyword: fixedRuntimeResult.mainKeyword,
            candidateCount: fixedRuntimeResult.candidateCount,
            insertedCount: fixedRuntimeResult.finalProducts.length,
            duplicateCount: fixedRuntimeResult.duplicateCount,
            keywordHitRate: fixedRuntimeResult.hitRate,
            stopReason: "",
          },
        ];
        await syncFinalResult(
          options.materialId,
          options.batchId,
          buildFinalResultPayload(
            options.materialId,
            fixedRuntimeResult.source,
            fixedRuntimeResult.mainKeyword,
            fixedRuntimeResult.finalProducts,
          ),
          fixedRuntimeResult.finalProducts,
          await readSearchRoundTitleFallbackProducts(search.initialRounds as Record<string, unknown>[]),
        );
        syncLog(
          `固定运行时阶段完成: 候选=${fixedRuntimeResult.candidateCount} 重复=${fixedRuntimeResult.duplicateCount} 命中率=${formatNullableHitRate(fixedRuntimeResult.hitRate)} 最终入围=${fixedRuntimeResult.finalProducts.length}`,
        );
        break;
      }

      if (search.bestMatchImage?.image_path) {
        latestKeywordStage = { search, keywordResult: stageKeywordResult, stage };
      }

      const stageOutcome = await resolveKeywordStageProducts(search, options.contactLimit);
      ({ accumulatedProducts, persistedStageProducts, completedStages } = await syncAccumulatedStageResult({
        materialId: options.materialId,
        batchId: options.batchId,
        stage,
        source: stageOutcome.source,
        mainKeyword: stageOutcome.mainKeyword,
        products: stageOutcome.products,
        stageKeywordHitRate: stageOutcome.stageKeywordHitRate,
        placeholderReason: stageOutcome.placeholderReason,
        accumulatedProducts,
        persistedStageProducts,
        completedStages,
      }));

      if (stageOutcome.stopPipeline) {
        syncLog(`阶段 ${stage.stageOrder} ${stage.label} 触发停止条件，终止后续关键词阶段与图搜阶段`);
        break;
      }
      continue;
    }

    if (!latestKeywordStage) {
      syncLog(`阶段 ${stage.stageOrder} ${stage.label} 跳过：缺少可用的 best match 图片`);
      continue;
    }

    let img: ImageSearchResult | null = null;
    try {
      img = await stageImageSearch(
        options,
        latestKeywordStage.search.searchRunSummaryPath,
        latestKeywordStage.keywordResult,
      );
      await syncImageSearch(options.materialId, options.batchId, img);
    } catch (error) {
      const message = error instanceof Error ? error.message : String(error);
      syncLog(`阶段 ${stage.stageOrder} ${stage.label} 执行失败，回退到关键词阶段结果: ${message}`);
    }

    const stageOutcome = await resolveImageStageOutcome({
      search: latestKeywordStage.search,
      imageResult: img,
      limit: options.contactLimit,
    });
    ({ accumulatedProducts, persistedStageProducts, completedStages } = await syncAccumulatedStageResult({
      materialId: options.materialId,
      batchId: options.batchId,
      stage,
      source: stageOutcome.source,
      mainKeyword: stageOutcome.mainKeyword,
      products: stageOutcome.products,
      stageKeywordHitRate: stageOutcome.stageKeywordHitRate,
      accumulatedProducts,
      persistedStageProducts,
      completedStages,
    }));
  }

  if (completedStages.length === 0) {
    await syncFinalResult(options.materialId, options.batchId, buildFinalResultPayload(options.materialId, "multi_stage_search", "", []));
    console.log("\n╔════════════════════ 正式联调结果（sync）════════════════════╗");
    console.log(`  物料ID:               ${options.materialId}`);
    console.log(`  流程状态:             所有搜索阶段均被跳过`);
    console.log(`  最终结果:             已同步 0 条商品`);
    console.log(`  Supabase:             已同步`);
    console.log("═══════════════════════════════════════════════════════════\n");
    return;
  }

  console.log("\n╔════════════════════ 正式联调结果（sync）════════════════════╗");
  console.log(`  物料ID:               ${options.materialId}`);
  console.log(`  已执行阶段:           ${completedStages.map((stage) => stage.label).join(" → ")}`);
  console.log(`  阶段候选总数:         ${completedStages.reduce((sum, stage) => sum + stage.candidateCount, 0)}`);
  console.log(`  去重过滤数量:         ${completedStages.reduce((sum, stage) => sum + stage.duplicateCount, 0)}`);
  console.log(`  最终结果:             已同步 ${accumulatedProducts.length} 条商品`);
  console.log(`  Supabase:             已同步`);
  console.log("═══════════════════════════════════════════════════════════\n");
}

if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
  main()
    .catch((error) => {
      console.error("\n❌ 同步模式执行失败:", error);
      process.exitCode = 1;
    })
    .finally(async () => {
      try {
        await cleanupTemporaryOutput();
        syncLog("本地临时 output 已保留");
      } catch (error) {
        const msg = error instanceof Error ? error.message : String(error);
        syncLog(`清理本地临时 output 失败: ${msg}`);
      }
    });
}
