import type { ParsedTriplets, NodeRole } from './triplet-parser';
import { resolveNodeRole, isVirtualIoName } from './triplet-parser';
import { wasmWorkerUrl, RUNTIME_NATIVE_COMPONENT_IDS } from '../lib/component-loader';
import { resolveRecipe } from '../routes/recipes';
import type { RecipeDefinition } from '../routes/recipes';
import { branchHintFor } from '../lib/branch-hints';
import type { BranchHint } from '../lib/branch-hints';

/**
 * `not_found` 而非 `missing`：欄位契約以頂層機械考
 * `system-dev/docs/3-specs/arcrun-usable/verify.sh` 為準（01 組 grep `not_found`）。
 */
/** `unchecked`＝compile 模式的誠實標記：沒查、不知道有沒有（≠found 的假信號）。 */
/** `resolved`＝意圖節點被媒合替換成真實零件/recipe（步驟 4；≠字面 exact 的 found）。 */
export type NodeStatus = 'found' | 'not_found' | 'unknown' | 'unchecked' | 'resolved';

/**
 * 意圖節點 → 真實零件/recipe 的替換結果（CP 步驟 4，workflow-discovery 3.x 搜尋端延伸）。
 * 目的（CP 原文）：AI 只要填 payload——系統把「傳到 telegram」翻成
 * `http_request`＋recipe `telegram_send`，並明說缺什麼。
 */
export type NodeSubstitution = {
  /** 原始意圖節點名（替換前）。 */
  from: string;
  /**
   * 執行底層零件：component 替換＝該零件本身；
   * recipe 替換＝`http_request`（recipe 是 http_request＋參數模板的具名封裝）。
   */
  componentId: string;
  /** recipe 替換時的 canonical_id——workflow config 寫 `component: <此值>` 即可直接用。 */
  recipe?: string;
  /** 為什麼這樣換（簡單可解釋規則的命中說明，不接 LLM）。 */
  reason: string;
};

/**
 * 指定搜尋對象（leo 07-31：「難道我不能指定要搜尋工作流或節點或 recipe 嗎？」）。
 * 不給＝現行混搜兩庫＋意圖替換；`component`＝只查零件 registry；`recipe`＝只查 recipe 庫。
 * `workflow` 不進本函式——workflow 搜尋是名字搜尋（route 層走既有 /workflows/search 機制）。
 */
export type SearchTarget = 'component' | 'recipe';

export type NodeInfo = {
  status: NodeStatus;
  componentId?: string;
  type: NodeRole;
  /**
   * found 時標來源庫：零件 registry（component）、recipe 庫（recipe），
   * 或 cypher-executor 自帶、無須查 registry 即保證解析得動的執行期原生零件（builtin，
   * Arcrun#88——component-loader.ts 的 RUNTIME_NATIVE_COMPONENT_IDS）。
   */
  source?: 'component' | 'recipe' | 'builtin';
  /** 零件契約（found 時附上，讓 AI 知道怎麼填 payload）。 */
  input_schema?: unknown;
  /** 成功率（found 時附上，讓「被測過幾次」看得見）。 */
  success_rate?: number;
  stability?: string;
  /** recipe found 時附上（AI 看得懂這個 recipe 在打哪個 API）。 */
  description?: string;
  endpoint?: string;
  /**
   * recipe 的 payload／回應用法自我說明（3.12，同 branch_hint 的動機）：
   * 逐顆查 recipe 時光看 endpoint 不知道「payload 怎麼填、回應怎麼取值」⇒ 會退回寫 code。
   */
  payload_hint?: {
    /** 這個 recipe 期望的 body 形狀（body_template 的欄位骨架，值是 {{var}} 佔位） */
    body_template?: unknown;
    /** 回應正規化規則存在時，說明取值路徑等 */
    response_map?: unknown;
    /** 一行說明：怎麼用這個 recipe */
    usage: string;
  };
  /**
   * not_found 時的分型指路（task 3.7）：兩庫（零件 registry＋recipe 庫）都查過才點名，
   * 並告訴 AI 該走哪條補件路＋去哪裡看做法。欄位名 `suggestion`（單數字串）＝verify.sh 03 組契約。
   */
  suggestion?: string;
  /** not_found 時的相近零件候選（自然語言節點名 → 既有零件的媒合）。 */
  similar_components?: string[];
  /** not_found 時的相近 recipe 候選。 */
  similar_recipes?: string[];
  /** resolved 時的替換明細（步驟 4：意圖節點 → 真實零件/recipe）。 */
  substitution?: NodeSubstitution;
  /**
   * 分支用法自我說明（3.11）：只有「本身會分岔」的零件才有
   * （if_control／switch／try_catch）。
   * 存在的理由＝走 n8n 式「逐顆查、自己組圖」的 AI，光看 input_schema 不知道
   * 「判斷完之後兩條路怎麼接」⇒ 會回頭寫 code。判準：只看這一顆的回應就知道怎麼接下一步。
   */
  branch_hint?: BranchHint;
};

export type SearchResult = {
  nodeResults: Record<string, NodeInfo>;
  missingNodes: string[];
};

/**
 * t158（leo 07-31 定調「部署≠發現」）：
 *   「這裡只是複製一些工作流的 data 過去，沒有要在這裡驗證，難怪這麼慢。
 *    就算是我自己寫了錯的工作流，也可以跑跑看，如果錯誤就修改，
 *    沒有說有錯誤還要一個個驗證這回事。」
 * - `compile`＝純編圖：**零外部查詢**（不打 registry、不掃 recipe、不算相似度、不擋 missing）。
 *   部署/推送/執行路徑用——寫錯的 workflow 照樣部署，錯在執行時現形。
 * - `discover`＝誠實查詢（預設，`/cypher/search` 的既有契約）：AI 問「有沒有」時用，
 *   not_found＋分型指路＋相似候選全保留。
 */
export type SearchMode = 'discover' | 'compile';

/** searchNodes 需要的環境子集（cypher-handlers 傳整份 Bindings 進來也相容）。 */
export type SearchNodesEnv = {
  WORKER_SUBDOMAIN?: string;
  /**
   * registry 位置覆蓋（可選，非機密）。未設 → 用 wasmWorkerUrl('registry', WORKER_SUBDOMAIN)
   * 現算（比照 KBDB_GRAPH_URL 慣例）。本地 wrangler dev / self-hosted 把 registry 掛別處時用。
   */
  REGISTRY_BASE_URL?: string;
  /** recipe 庫（本 worker 自己的 KV；task 3.6 兩庫都查的第二庫）。 */
  RECIPES?: KVNamespace;
};

/**
 * 對所有節點進行解析，確認每個節點對應的零件／recipe 是否**真的存在**。
 *
 * ⚠️ 2026-07-30 改為會查 registry（workflow-discovery task 3.x）；
 *    2026-07-31 再加 recipe 庫查詢＋缺件分型指路（task 3.6／3.7）。
 *
 * 改之前的行為（病灶）：無條件回 `status: 'found'`、`missingNodes` 永遠是空陣列——
 * 實測「完全不存在的東西xyz」也回 found。
 *
 * 為什麼這是嚴重問題（leo 2026-07-30 定性「腹語術」）：
 *   AI 寫意圖 → 查詢回「都 found」（假信號）→ 實際零件不存在
 *   → 部署/執行才發現 → 最快的修法是改寫成 `code` 節點自己寫 JS
 *   → 於是正式 workflow 只用 2 個零件、8 個 code 節點含 if×61
 *   ⇒ 「零件被測過 1000 次所以 AI 只要填 payload」的價值完全落空。
 *
 * 設計基調（leo 2026-07-31 二次定調）：**回覆的重點是「缺哪些」不是「有哪些」**——
 * 有的照常編圖不必報告；缺的要兩庫（零件 registry＋recipe 庫）都搜過後點名＋給正確指示：
 *   缺外部 API → 自己寫 recipe（skill `write_recipe`）；
 *   缺計算原語 → 投稿零件 PR（skill `add_new_wasm_component`）。
 *
 * 誠實限制：查不到 registry（未部署／網路失敗）時回 `'unknown'` 而不是 `'not_found'`——
 * 不能因為查詢失敗就宣告零件不存在（那會讓 AI 誤判而重寫 code，正是要避免的事）。
 */
export async function searchNodes(
  parsed: ParsedTriplets,
  config?: Record<string, Record<string, unknown>>,
  env?: SearchNodesEnv,
  mode: SearchMode = 'discover',
  target?: SearchTarget,
): Promise<SearchResult> {
  const nodeResults: Record<string, NodeInfo> = {};
  const missingNodes: string[] = [];

  // ── compile：純編圖，零外部查詢（t158，部署≠發現）─────────────────────────
  if (mode === 'compile') {
    for (const nodeName of parsed.nodeNames) {
      const role = resolveNodeRole(nodeName, parsed);
      if ((role === 'Input' || role === 'Output') && isVirtualIoName(nodeName)) {
        nodeResults[nodeName] = { status: 'found', componentId: nodeName.toLowerCase(), type: role };
        continue;
      }
      const configComponent = config?.[nodeName]?.component as string | undefined;
      // unchecked＝誠實「沒查」；存在性由 component-loader 在執行時決定
      nodeResults[nodeName] = {
        status: configComponent ? 'found' : 'unchecked',
        componentId: configComponent ?? nodeName,
        type: role,
      };
    }
    return { nodeResults, missingNodes };
  }

  const sub = env?.WORKER_SUBDOMAIN;
  const registryBase = env?.REGISTRY_BASE_URL ?? (sub ? wasmWorkerUrl('registry', sub) : undefined);

  // target 限庫（leo 07-31）：component＝只查零件 registry；recipe＝只查 recipe 庫。
  // 不給＝混搜兩庫（既有行為）。
  const wantComponents = target !== 'recipe';
  const wantRecipes = target !== 'component';

  // ── discover 批次化（t158）：兩庫各抓**一次**，之後全在記憶體內比對。────────
  // 病史（07-31 stage 實測）：舊版對每個 missing 節點各打「1 次逐顆查＋最多 9 次
  // 相似搜尋＋一輪 recipe KV 掃描」⇒ 冷實例 8 節點 /cypher/search 25.7s，
  // 安裝器 15s timeout 必炸。批次化後每 request 固定 1 次 catalog＋1 次 recipe 清單。
  // 步驟 4 的意圖替換也在**同一份清單**上做——不加任何新 round-trip。
  const catalog = !wantComponents
    ? { status: 'ok' as const, entries: [] } // target=recipe：registry 不參與，不因此回 unknown
    : registryBase ? await fetchCatalog(registryBase) : { status: 'unreachable' as const, entries: [] };
  const recipes = wantRecipes && env?.RECIPES ? await listAllRecipes(env.RECIPES) : [];
  const byId = new Map<string, CatalogFullRecord>();
  for (const e of catalog.entries) {
    const prev = byId.get(e.canonical_id);
    if (!prev || (e.score ?? 0) > (prev.score ?? 0)) byId.set(e.canonical_id, e);
    for (const a of e.aliases ?? []) if (!byId.has(a)) byId.set(a, e);
  }

  for (const nodeName of parsed.nodeNames) {
    const role = resolveNodeRole(nodeName, parsed);

    // 只有**字面上的虛擬 IO 名**（input/trigger/…/output/done）才免查——
    // 位置上是頭節點但名字是真零件（`aes_encrypt >> … >> code` 的頭，role 也是 Input）
    // 仍要照常查兩庫，否則缺件被角色掩蓋、又回到「假 found」。
    if ((role === 'Input' || role === 'Output') && isVirtualIoName(nodeName)) {
      nodeResults[nodeName] = { status: 'found', componentId: nodeName.toLowerCase(), type: role };
      continue;
    }

    const configComponent = config?.[nodeName]?.component as string | undefined;
    const componentId = configComponent ?? nodeName;

    // config 明確給了 component（多半是安裝器代入的 worker URL 或既有 workflow）
    // → 不判 not_found。這條路徑的存在性由 component-loader 在執行時決定（原行為）。
    if (configComponent) {
      nodeResults[nodeName] = { status: 'found', componentId, type: role };
      continue;
    }

    // ── 執行期原生零件（Arcrun#88）：查 registry 之前先比對 ──────────────────
    // component-loader.ts 的 RUNTIME_NATIVE_COMPONENT_IDS＝trigger_workflow／
    // BUILTIN_COMPONENTS／LOGIC_BINDING_MAP／WASM_HTTP_RUNNER_IDS 的聯集——
    // 這些零件 cypher-executor 自己就能 resolve，從不查 registry，執行期保證解析得動。
    // 病史：registry 是空的／未部署新版 `/catalog` 端點時，這批零件（if_control／
    // http_request／switch…）會被下面「兩庫都查過沒有」誤判成 not_found——
    // 而 leo 08-11 實測探測工作流證明它們跑得動。命中即 found，不受 registry 健康狀態影響。
    // target=recipe（使用者明確只要查 recipe 庫）不適用——這些從來不是 recipe。
    if (wantComponents && RUNTIME_NATIVE_COMPONENT_IDS.has(componentId)) {
      nodeResults[nodeName] = {
        status: 'found',
        componentId,
        type: role,
        source: 'builtin',
        branch_hint: branchHintFor(componentId),
      };
      continue;
    }

    // registry 完全查不通（未部署／網路失敗）⇒ 誠實回 unknown。
    // **不能誤判 not_found**——那會讓 AI 以為零件不存在而重寫 code，正是要避免的事。
    // 舊 registry 沒有 /catalog 端點（no_endpoint）→ 退回逐顆查（相容路徑）。
    if (catalog.status === 'unreachable') {
      nodeResults[nodeName] = { status: 'unknown', componentId, type: role };
      continue;
    }
    if (catalog.status === 'no_endpoint') {
      const legacy = await legacyPerNodeLookup(registryBase!, componentId, nodeName, role, env, recipes);
      nodeResults[nodeName] = legacy.info;
      if (legacy.missing) missingNodes.push(nodeName);
      continue;
    }

    // ── 第一庫：零件 catalog（記憶體）────────────────────────────────────────
    const hit = byId.get(componentId);
    if (hit) {
      nodeResults[nodeName] = {
        status: 'found',
        componentId,
        type: role,
        source: 'component',
        input_schema: hit.input_schema,
        success_rate: typeof hit.success_rate === 'number' ? hit.success_rate : undefined,
        stability: typeof hit.stability === 'string' ? hit.stability : undefined,
        branch_hint: branchHintFor(componentId),
      };
      continue;
    }

    // ── 第二庫：recipe 清單（記憶體；canonical_id 精確比對）──────────────────
    const recipe = recipes.find(r => r.canonical_id === componentId);
    if (recipe) {
      nodeResults[nodeName] = {
        status: 'found',
        componentId: recipe.canonical_id,
        type: role,
        source: 'recipe',
        description: recipe.description,
        endpoint: recipe.endpoint,
        payload_hint: buildPayloadHint(recipe),
      };
      continue;
    }

    // ── 步驟 4：意圖節點 → 真實零件/recipe 替換（同一份清單、全記憶體）────────
    // 字面 exact 兩庫都落空的自然語言節點（例「傳到 telegram」「判斷有沒有新資料」），
    // 先試保守的替換規則；換得到＝resolved（回應直接可組 workflow），換不到才 not_found。
    const substituted = trySubstitution(nodeName, catalog.entries, recipes);
    if (substituted) {
      nodeResults[nodeName] = { ...substituted, type: role };
      continue;
    }

    // ── 兩庫都沒有 ⇒ not_found ＋ 分型指路（task 3.7）＋ 相近候選（全記憶體）──
    const similarComponents = similarFromCatalog(catalog.entries, nodeName);
    const similarRecipes = similarFromRecipes(recipes, nodeName);

    nodeResults[nodeName] = {
      status: 'not_found',
      componentId,
      type: role,
      suggestion: buildSuggestion(componentId),
      ...(similarComponents.length > 0 ? { similar_components: similarComponents } : {}),
      ...(similarRecipes.length > 0 ? { similar_recipes: similarRecipes } : {}),
    };
    missingNodes.push(nodeName);
  }

  return { nodeResults, missingNodes };
}

// ── t158 批次化 helpers ────────────────────────────────────────────────────────

type CatalogFullRecord = {
  canonical_id: string;
  display_name?: string;
  description?: string;
  aliases?: string[];
  tags?: string[];
  score?: number;
  input_schema?: unknown;
  success_rate?: number;
  stability?: string;
};

type CatalogFetch = { status: 'ok' | 'no_endpoint' | 'unreachable'; entries: CatalogFullRecord[] };

/** 一次抓 registry 全目錄。404＝舊版 registry 沒這端點 → 呼叫端退回逐顆查。 */
async function fetchCatalog(registryBase: string): Promise<CatalogFetch> {
  try {
    const res = await fetch(`${registryBase}/components/catalog`, { signal: AbortSignal.timeout(10000) });
    if (res.status === 404) return { status: 'no_endpoint', entries: [] };
    if (!res.ok) return { status: 'unreachable', entries: [] };
    const body = (await res.json()) as { data?: { components?: CatalogFullRecord[] } };
    return { status: 'ok', entries: body.data?.components ?? [] };
  } catch {
    return { status: 'unreachable', entries: [] };
  }
}

/** 一次抓 recipe 全清單（本部署 recipe 數量小；exact 與相似度共用同一份）。
 *  export 給 target=recipe 的名字搜尋（actions/target-search.ts）共用同一份讀法。 */
export async function listAllRecipes(kv: KVNamespace): Promise<RecipeDefinition[]> {
  try {
    const list = await kv.list({ prefix: 'recipe:' });
    return (await Promise.all(
      list.keys.map(k => kv.get(k.name, 'json') as Promise<RecipeDefinition | null>),
    )).filter(Boolean) as RecipeDefinition[];
  } catch {
    return [];
  }
}

/** 相似零件（記憶體版）：全名 substring 優先，否則斷詞計數 top3——判準與舊 HTTP 版一致。 */
function similarFromCatalog(entries: CatalogFullRecord[], nodeName: string): string[] {
  const searchableOf = (e: CatalogFullRecord) =>
    [e.canonical_id, e.display_name ?? '', e.description ?? '', ...(e.aliases ?? []), ...(e.tags ?? [])]
      .join(' ').toLowerCase();
  const full = nodeName.toLowerCase();
  const direct = entries.filter(e => searchableOf(e).includes(full)).map(e => e.canonical_id);
  if (direct.length > 0) return [...new Set(direct)].slice(0, 3);

  const tokens = extractTokens(nodeName);
  if (tokens.length === 0) return [];
  const count = new Map<string, number>();
  for (const e of entries) {
    const hay = searchableOf(e);
    const hits = tokens.filter(t => hay.includes(t)).length;
    if (hits > 0) count.set(e.canonical_id, Math.max(count.get(e.canonical_id) ?? 0, hits));
  }
  return [...count.entries()].sort((a, b) => b[1] - a[1]).slice(0, 3).map(([id]) => id);
}

/** 相似 recipe（記憶體版；判準沿用 searchSimilarRecipes）。 */
function similarFromRecipes(recipes: RecipeDefinition[], nodeName: string): string[] {
  const tokens = [nodeName.toLowerCase(), ...extractTokens(nodeName)];
  const seen = new Set<string>();
  const matched: string[] = [];
  for (const r of recipes) {
    if (seen.has(r.canonical_id)) continue;
    const hay = `${r.canonical_id} ${r.display_name ?? ''} ${r.description ?? ''}`.toLowerCase();
    if (tokens.some(t => hay.includes(t))) {
      seen.add(r.canonical_id);
      matched.push(r.canonical_id);
    }
  }
  return matched.slice(0, 3);
}

/** 舊 registry（無 /catalog 端點）的相容路徑：維持逐顆查語義。 */
async function legacyPerNodeLookup(
  registryBase: string,
  componentId: string,
  nodeName: string,
  role: NodeRole,
  env: SearchNodesEnv | undefined,
  recipes: RecipeDefinition[],
): Promise<{ info: NodeInfo; missing: boolean }> {
  const q = await fetchComponent(registryBase, componentId);
  if (!q.ok) return { info: { status: 'unknown', componentId, type: role }, missing: false };
  if (q.entry) {
    return {
      info: {
        status: 'found', componentId, type: role, source: 'component',
        input_schema: q.entry.input_schema, success_rate: q.entry.success_rate, stability: q.entry.stability,
        branch_hint: branchHintFor(componentId),
      },
      missing: false,
    };
  }
  const recipe = recipes.find(r => r.canonical_id === componentId)
    ?? (env?.RECIPES ? await resolveRecipe(componentId, env.RECIPES) : null);
  if (recipe) {
    return {
      info: {
        status: 'found', componentId: recipe.canonical_id, type: role, source: 'recipe',
        description: recipe.description, endpoint: recipe.endpoint,
        payload_hint: buildPayloadHint(recipe),
      },
      missing: false,
    };
  }
  const similarComponents = await searchSimilarComponents(registryBase, nodeName);
  const similarRecipes = similarFromRecipes(recipes, nodeName);
  return {
    info: {
      status: 'not_found', componentId, type: role, suggestion: buildSuggestion(componentId),
      ...(similarComponents.length > 0 ? { similar_components: similarComponents } : {}),
      ...(similarRecipes.length > 0 ? { similar_recipes: similarRecipes } : {}),
    },
    missing: true,
  };
}

// ── 步驟 4：意圖節點 → 真實零件/recipe 替換 ────────────────────────────────────
//
// 目的（CP arcrun-usable 步驟 4）：AI 只要填 payload——系統把「傳到 telegram」翻成
// `http_request`＋recipe `telegram_send`。媒合在「一次抓好的兩庫清單」記憶體內做，
// 零新增 round-trip；規則沿用 task 3.7 的服務詞判型＋既有斷詞媒合（extractTokens），
// 刻意簡單可解釋、不接 LLM。
//
// 兩條規則（保守——換錯比不換更糟，寧可 not_found＋候選讓 AI 自己選）：
//   A) 服務詞規則（recipe 路）：節點名含 SERVICE_HINTS 服務詞 → 名字裡**全部**服務詞
//      都命中同一個 recipe、且該 recipe **唯一**才替換。
//      例「傳到 telegram」：服務詞 [telegram] → 唯一命中 telegram_send ⇒ 換。
//      反例「google_slides_create」：服務詞 [google, slides] → google_sheets_* 只中
//      google 不中 slides ⇒ 不換（照 3.7 指去寫 recipe）。
//      有服務詞的節點**不落入規則 B**——外部服務就該是 recipe，不硬配零件
//      （否則「google_slides」會被 display_name 含 Google 的零件誤吃）。
//   B) 強欄位規則（零件路）：斷詞後只算**強欄位**（canonical_id／display_name／aliases）
//      命中為主：分數＝強命中×10＋弱命中（description／tags）×1，
//      需「至少一個強命中」且「分數唯一最高」才替換。
//      例「判斷有沒有新資料」：2-gram「判斷」命中 if_control display_name「條件判斷」
//      （強 10 分），try_catch 只在 description 中「判斷」（弱 1 分）⇒ 唯一最高 ⇒ 換。
//      反例「aes_encrypt」：無任何強命中 ⇒ 不換（照 3.7 指去投零件 PR）。

type SubstitutionHit = Pick<
  NodeInfo,
  'status' | 'componentId' | 'source' | 'substitution' |
  'input_schema' | 'success_rate' | 'stability' | 'description' | 'endpoint' | 'branch_hint'
>;

function trySubstitution(
  nodeName: string,
  catalogEntries: CatalogFullRecord[],
  recipes: RecipeDefinition[],
): SubstitutionHit | null {
  const lower = nodeName.toLowerCase();
  const serviceHits = SERVICE_HINTS.filter(w => lower.includes(w));

  // 規則 A：服務詞 → recipe（全部服務詞命中＋唯一）
  if (serviceHits.length > 0) {
    const matched = new Map<string, RecipeDefinition>();
    for (const r of recipes) {
      const hay = `${r.canonical_id} ${r.display_name ?? ''} ${r.description ?? ''}`.toLowerCase();
      if (serviceHits.every(h => hay.includes(h))) matched.set(r.canonical_id, r);
    }
    if (matched.size !== 1) return null; // 0＝真缺件走 not_found；≥2＝歧義，候選留給 similar_recipes
    const recipe = [...matched.values()][0];
    return {
      status: 'resolved',
      componentId: recipe.canonical_id,
      source: 'recipe',
      description: recipe.description,
      endpoint: recipe.endpoint,
      substitution: {
        from: nodeName,
        componentId: 'http_request', // recipe＝http_request＋參數模板的具名封裝
        recipe: recipe.canonical_id,
        reason:
          `服務詞「${serviceHits.join('、')}」唯一命中 recipe「${recipe.canonical_id}」；` +
          `workflow config 寫 component: ${recipe.canonical_id}（底層零件＝http_request），只需填 payload`,
      },
    };
  }

  // 規則 B：強欄位斷詞媒合 → 零件（至少一強命中＋分數唯一最高）
  const tokens = extractTokens(nodeName);
  if (tokens.length === 0) return null;

  type Scored = { entry: CatalogFullRecord; score: number; strongHits: string[] };
  const byCanonical = new Map<string, Scored>();
  for (const e of catalogEntries) {
    const strongHay = [e.canonical_id, e.display_name ?? '', ...(e.aliases ?? [])].join(' ').toLowerCase();
    const weakHay = [e.description ?? '', ...(e.tags ?? [])].join(' ').toLowerCase();
    const strongHits = tokens.filter(t => strongHay.includes(t));
    const weakCount = tokens.filter(t => weakHay.includes(t)).length;
    const score = strongHits.length * 10 + weakCount;
    if (score === 0) continue;
    const prev = byCanonical.get(e.canonical_id);
    if (!prev || score > prev.score) byCanonical.set(e.canonical_id, { entry: e, score, strongHits });
  }
  const ranked = [...byCanonical.values()].sort((a, b) => b.score - a.score);
  const top = ranked[0];
  if (!top || top.strongHits.length === 0) return null;          // 沒有強命中＝證據不足
  if (ranked[1] && ranked[1].score >= top.score) return null;    // 同分歧義＝不硬猜

  return {
    status: 'resolved',
    componentId: top.entry.canonical_id,
    source: 'component',
    input_schema: top.entry.input_schema,
    success_rate: typeof top.entry.success_rate === 'number' ? top.entry.success_rate : undefined,
    stability: typeof top.entry.stability === 'string' ? top.entry.stability : undefined,
    // 替換成分岔零件時（例「判斷有沒有新資料」→ if_control）一併附分支用法，
    // 否則 AI 換到零件卻不知道怎麼接兩條路，仍會退回寫 code。
    branch_hint: branchHintFor(top.entry.canonical_id),
    substitution: {
      from: nodeName,
      componentId: top.entry.canonical_id,
      reason:
        `斷詞「${top.strongHits.join('、')}」命中零件「${top.entry.canonical_id}」` +
        `（${top.entry.display_name ?? ''}）強欄位且分數唯一最高；只需照 input_schema 填 payload`,
    },
  };
}

// ── 缺件分型（task 3.7）────────────────────────────────────────────────────────
//
// 分型判準（刻意用簡單可解釋的規則，不接 LLM——查詢端點要快、要可預測）：
//   1) 名字含**外部服務詞**（google／telegram／slack…）→「外部 API 樣貌」
//      → recipe 路：recipe 是 http_request＋參數模板的具名封裝，用戶自己就能寫，不用改平台。
//   2) 否則名字含**計算原語詞**（encrypt／hash／encode…）→「計算原語樣貌」
//      → 零件路：純計算得進 WASM 沙箱跑，要走 GitHub PR 投稿（人 merge＝人類閘門，mindset §4）。
//   3) 都不含 → 判不出型，誠實說判不出，兩條路都給（不硬猜——猜錯會把人指去錯的路）。
// 判斷順序：服務詞優先於計算詞——「google_sheets_parse」雖含 parse，本質仍是打外部 API。

const SERVICE_HINTS = [
  'google', 'gmail', 'sheets', 'slides', 'gdocs', 'drive', 'calendar', 'youtube',
  'slack', 'telegram', 'discord', 'line', 'whatsapp', 'twilio',
  'notion', 'airtable', 'trello', 'jira', 'asana', 'linear',
  'github', 'gitea', 'gitlab', 'bitbucket',
  'stripe', 'paypal', 'shopify', 'hubspot', 'salesforce',
  'openai', 'anthropic', 'claude', 'gemini', 'groq',
  'twitter', 'facebook', 'instagram', 'linkedin', 'dropbox', 'zoom',
  'sendgrid', 'mailgun', 'kbdb',
];

const COMPUTE_HINTS = [
  'encrypt', 'decrypt', 'cipher', 'aes', 'rsa', 'sha', 'md5', 'hmac', 'hash',
  'sign', 'verify', 'encode', 'decode', 'base64', 'hex',
  'compress', 'decompress', 'zip', 'gzip',
  'uuid', 'random', 'regex', 'math', 'calc',
  'sort', 'dedup', 'diff', 'template', 'render', 'convert', 'transform',
  'parse', 'format', 'csv', 'xml',
];

function buildSuggestion(componentId: string): string {
  const lower = componentId.toLowerCase();
  const serviceHit = SERVICE_HINTS.find(w => lower.includes(w));
  const computeHit = COMPUTE_HINTS.find(w => lower.includes(w));

  if (serviceHit) {
    return (
      `兩庫都查過，零件 registry 與 recipe 庫皆無「${componentId}」。` +
      `名字含服務詞「${serviceHit}」＝外部 API 樣貌 → 沒有此 recipe，可自己寫：` +
      `寫法看 skill「write_recipe」（arcrun_get_skill('write_recipe')），` +
      `寫好用 acr recipe push 或 POST /recipes 裝上即可用，不用改平台。`
    );
  }
  if (computeHit) {
    return (
      `兩庫都查過，零件 registry 與 recipe 庫皆無「${componentId}」。` +
      `名字含計算詞「${computeHit}」＝計算原語樣貌 → 沒有此零件，可投稿 PR 新增 WASM component：` +
      `做法看 skill「add_new_wasm_component」（arcrun_get_skill('add_new_wasm_component')）。`
    );
  }
  return (
    `兩庫都查過，零件 registry 與 recipe 庫皆無「${componentId}」，且名字判不出型。` +
    `缺外部 API → 自己寫 recipe（skill「write_recipe」）；` +
    `缺計算能力 → 投稿零件 PR（skill「add_new_wasm_component」，component 進 WASM 沙箱）。`
  );
}

/**
 * recipe 的 payload／回應用法自我說明（3.12）。
 * 動機同 branch_hint：逐顆查 recipe（n8n 式）時，光看 endpoint 不知道 payload 怎麼填、
 * 回應怎麼取值 ⇒ AI 會退回把整包寫進 workflow code。
 */
export function buildPayloadHint(recipe: RecipeDefinition): NodeInfo['payload_hint'] {
  const parts: string[] = [];

  if (recipe.body_template) {
    parts.push('payload 已收在 recipe 的 body_template 裡，你只要把 {{變數}} 對應的值放進節點 context');
  } else if (recipe.body) {
    parts.push('payload 形狀見 body 欄位（{{變數}} 由節點 context 填）');
  } else {
    parts.push('未定義 body_template：節點 context 會整包當 body 送出（_ 開頭的內部欄位會被剔除）');
  }

  if (recipe.response_map) {
    parts.push('回應已正規化：執行結果除了原始 data，另附 text（取值路徑等規則寫在 recipe 裡，換源不必改 workflow）');
  } else {
    parts.push('未定義 response_map：回應原樣放在 data，取值要自己指路徑');
  }

  if (recipe.auth === 'binding') {
    parts.push(`認證＝binding（免金鑰，用平台內建 ${recipe.binding_name ?? 'AI'}）`);
  } else if (recipe.auth_service) {
    parts.push(`認證走 auth recipe「${recipe.auth_service}」（金鑰由系統在執行前注入，你不必也不該填）`);
  }

  return {
    body_template: recipe.body_template,
    response_map: recipe.response_map,
    usage: parts.join('；') + '。',
  };
}

// ── registry 查詢 ─────────────────────────────────────────────────────────────

type CatalogEntry = {
  input_schema?: unknown;
  success_rate?: number;
  stability?: string;
};

/**
 * 查單一零件是否存在於 registry。
 *
 * ⚠️ 為什麼逐個查而非抓整份目錄：registry **沒有列表端點**
 * （實測 `GET /components` → 404，只有 `GET /components/<id>`）。
 * 這是 CP2-B 記載的缺口（「修 /components 404」）——補了列表端點後可改為抓一次。
 * 現階段逐個查：節點數通常 <10，且有 5s timeout，可接受。
 *
 * 回傳 `ok:false` 代表「查不到 registry」，由呼叫端區分：
 * 整體查不通 → `unknown`；查得通但這顆沒有 → 繼續查 recipe 庫。
 */
async function fetchComponent(
  registryBase: string,
  id: string,
): Promise<{ ok: boolean; entry?: CatalogEntry }> {
  try {
    const res = await fetch(`${registryBase}/components/${encodeURIComponent(id)}`, {
      signal: AbortSignal.timeout(5000),
    });
    if (res.status === 404) return { ok: true }; // registry 活著，但沒這顆
    if (!res.ok) return { ok: false };
    const body = (await res.json()) as { success?: boolean; data?: Record<string, unknown> };
    if (body.success === false) return { ok: true }; // 同上：回「零件不存在」
    const d = body.data ?? (body as unknown as Record<string, unknown>);
    return {
      ok: true,
      entry: {
        input_schema: d.input_schema,
        success_rate: typeof d.success_rate === 'number' ? d.success_rate : undefined,
        stability: typeof d.stability === 'string' ? d.stability : undefined,
      },
    };
  } catch {
    return { ok: false };
  }
}

// ── 相近候選（自然語言節點名 → 既有零件/recipe 的媒合）──────────────────────────
//
// 節點名常是自然語言（例「判斷有沒有新資料」）。leo：「AI 不用知道零件存在」——
// 所以 not_found 時要主動給相近候選，讓 AI 看回覆就知道「其實有 if_control 可用」。
// 做法：先拿全名打 registry `/components/search`；沒中再斷詞重試——
// ASCII 取 3 字以上的詞、中日韓取 2-gram（registry search 是子字串比對，整句中文必落空，
// 2-gram 才撈得到「判斷」→ if_control（display_name「條件判斷」）這種命中）。

function extractTokens(name: string): string[] {
  const tokens: string[] = [];
  const ascii = name.toLowerCase().match(/[a-z0-9]{3,}/g) ?? [];
  tokens.push(...ascii);
  const cjkRuns = name.match(/[一-鿿]+/g) ?? [];
  for (const run of cjkRuns) {
    for (let i = 0; i + 2 <= run.length; i++) tokens.push(run.slice(i, i + 2));
  }
  return [...new Set(tokens)].slice(0, 8); // 上限 8 個 token，避免對 registry 掃太多輪
}

async function searchRegistryIds(registryBase: string, q: string): Promise<string[]> {
  try {
    const res = await fetch(`${registryBase}/components/search?q=${encodeURIComponent(q)}`, {
      signal: AbortSignal.timeout(5000),
    });
    if (!res.ok) return [];
    const body = (await res.json()) as { data?: { results?: Array<{ canonical_id?: string }> } };
    return (body.data?.results ?? []).map(r => r.canonical_id).filter((s): s is string => !!s);
  } catch {
    return [];
  }
}

async function searchSimilarComponents(registryBase: string, nodeName: string): Promise<string[]> {
  // 1) 全名直接搜
  const direct = await searchRegistryIds(registryBase, nodeName);
  if (direct.length > 0) return direct.slice(0, 3);

  // 2) 斷詞搜，依命中次數排序
  const tokens = extractTokens(nodeName);
  if (tokens.length === 0) return [];
  const hits = await Promise.all(tokens.map(t => searchRegistryIds(registryBase, t)));
  const count = new Map<string, number>();
  for (const ids of hits) {
    for (const id of ids) count.set(id, (count.get(id) ?? 0) + 1);
  }
  return [...count.entries()].sort((a, b) => b[1] - a[1]).slice(0, 3).map(([id]) => id);
}

/** recipe 庫的相近候選：KV 全列（本部署 recipe 數量小）後子字串比對。 */
async function searchSimilarRecipes(kv: KVNamespace, nodeName: string): Promise<string[]> {
  try {
    const list = await kv.list({ prefix: 'recipe:' });
    const all = (await Promise.all(
      list.keys.map(k => kv.get(k.name, 'json') as Promise<RecipeDefinition | null>),
    )).filter(Boolean) as RecipeDefinition[];

    const tokens = [nodeName.toLowerCase(), ...extractTokens(nodeName)];
    const seen = new Set<string>();
    const matched: string[] = [];
    for (const r of all) {
      if (seen.has(r.canonical_id)) continue;
      const hay = `${r.canonical_id} ${r.display_name ?? ''} ${r.description ?? ''}`.toLowerCase();
      if (tokens.some(t => hay.includes(t))) {
        seen.add(r.canonical_id);
        matched.push(r.canonical_id);
      }
    }
    return matched.slice(0, 3);
  } catch {
    return [];
  }
}
