/**
 * deploy.ts — self-hosted Worker 部署（installer 的「下載 repo tarball + wrangler deploy」段）
 *
 * 對應 SDD：.agents/specs/arcrun/sdk-and-website/self-hosted-init.md §6（commit wasm + archive）
 *
 * 策略（richblack 2026-06-02）：repo 自帶預編譯 wasm（.component-builds 下各 component.wasm，
 * 見 rule 05 慣例變更）→ CLI 從 repo archive tarball 拿完整部署物 → 注入用戶的 KV id
 * → 用用戶自己的 CF token wrangler deploy。用戶不需 git / tinygo，只需 wrangler。
 *
 * 下載源（Arcrun#4，2026-07-07）：從 GitHub codeload 改指 Gitea（git.uncle6.me）archive API。
 *   動機：D20 防 flag 鐵律下 self-hosted 用戶（如 Mira）不能走 GitHub；且「init 之後才新增的
 *   零件」（如 code 零件）唯一重裝管道就是 acr update，綁死 GitHub codeload = 壞。改指 Gitea 後，
 *   用戶跑 acr update 就會把新零件補上、已裝的（內容指紋未變）略過（downloadAndDeploy 冪等）。
 *   fork/自架者可用 ARCRUN_REPO / ARCRUN_GITEA_BASE / GITEA_TOKEN env 覆蓋（見下方常數）。
 */

import { execFileSync } from 'node:child_process';
import { mkdtempSync, readFileSync, writeFileSync, existsSync, readdirSync, statSync } from 'node:fs';
import { tmpdir, homedir } from 'node:os';
import { join } from 'node:path';
import { createHash } from 'node:crypto';
import chalk from 'chalk';
import { CfAccountClient } from './cf-api.js';
import {
  applyResourcePlan,
  bindingKey,
  parseWranglerRequirements,
  planResources,
  ResourcePlanBlocked,
  TABLE_KIND,
  type BindingRequirement,
  type ResourceApi,
  type ResourceKind,
  type ResolvedResource,
} from './resource-resolver.js';

/** 部署狀態 manifest：記錄上次成功部署每個 worker 的內容指紋（content hash），
 *  讓 acr update 跳過未變動的 worker（壓測 2026-06-12：22/23 成功後重跑仍全部
 *  pnpm install + wrangler deploy，22 個沒變的白跑）。存 ~/.arcrun/。
 *  指紋含 wrangler.toml 注入後的內容 → 換帳號/KV 會變更指紋 → 自動重部，不會誤跳。*/
const MANIFEST_PATH = join(homedir(), '.arcrun', 'deploy-manifest.json');

function loadManifest(): Record<string, string> {
  try {
    return JSON.parse(readFileSync(MANIFEST_PATH, 'utf8')) as Record<string, string>;
  } catch {
    return {};
  }
}

function saveManifest(m: Record<string, string>): void {
  try {
    writeFileSync(MANIFEST_PATH, JSON.stringify(m, null, 2));
  } catch {
    /* manifest 寫失敗不致命：下次全部重部（退化成舊行為，不會錯，只是慢） */
  }
}

/** 算一個 worker 目錄的內容指紋：遞迴 hash 所有檔案（排除 node_modules），
 *  加上 accountId（換帳號要重部）。檔案路徑相對化後排序 → 跨機器/temp 目錄穩定。*/
function dirContentHash(dir: string, accountId: string): string {
  const h = createHash('sha256');
  h.update(accountId);
  const walk = (d: string, rel: string): void => {
    let entries: string[];
    try { entries = readdirSync(d).sort(); } catch { return; }
    for (const name of entries) {
      if (name === 'node_modules' || name === '.git') continue;
      const full = join(d, name);
      const relPath = rel ? `${rel}/${name}` : name;
      let st;
      try { st = statSync(full); } catch { continue; }
      if (st.isDirectory()) {
        walk(full, relPath);
      } else {
        h.update(relPath);
        try { h.update(readFileSync(full)); } catch { /* skip unreadable */ }
      }
    }
  };
  walk(dir, '');
  return h.digest('hex');
}

/** repo 路徑 owner/name（archive tarball 來源）。fork 者改這裡或用 ARCRUN_REPO env。
 *  注意：repo 名大小寫敏感（archive 路徑需完全一致）。
 *  Arcrun#4：真身在 Gitea（Leo/Arcrun），故預設從 GitHub 的 uncle6me-web/Arcrun 改為 Gitea 路徑。*/
const ARCRUN_REPO = process.env.ARCRUN_REPO ?? 'Leo/Arcrun';

/** Gitea 站台 base URL（archive API host）。fork/自架不同站台用 ARCRUN_GITEA_BASE env 覆蓋。
 *  末尾斜線會被正規化掉，避免組出 `//api`。*/
const ARCRUN_GITEA_BASE = (process.env.ARCRUN_GITEA_BASE ?? 'https://git.uncle6.me').replace(/\/+$/, '');

/** Gitea access token（private repo 下載用）。走既有 env 機制，不寫死。
 *  ARCRUN_GITEA_TOKEN 優先（專用），否則沿用 bootstrap 既有的 GITEA_TOKEN。
 *  public repo 可不設（回傳 undefined → 不帶 Authorization header 也能下載）。*/
function giteaToken(): string | undefined {
  return process.env.ARCRUN_GITEA_TOKEN || process.env.GITEA_TOKEN || undefined;
}

/**
 * 版本標籤的「發行頻道」來源（Arcrun#106）。
 *
 * Portal 設定頁與 daemon `cloudVersionStale()` 都是拿**這支**回的 `release` 當「最新版」，
 * 再跟實例 `/health` 的 `bundle_version` 比。CLI 更新完若不烙一個同一把尺量得出來的版號，
 * 使用者就只會看到「無法讀取目前版本」或永遠「落後」。
 * fork／自架另有發行頻道者用 ARCRUN_RELEASE_API 覆蓋，不寫死。
 */
const ARCRUN_RELEASE_API = process.env.ARCRUN_RELEASE_API ?? 'https://install.arcrun.dev/api/latest';

/** CLI 自己負責注入 / 自己烙的 var——**不從已部署的 worker 沿用**（沿用會蓋掉這趟算出來的正解）。 */
export const CLI_MANAGED_VARS = [
  'WORKER_SUBDOMAIN',        // 由 ctx.workerSubdomain 注入
  'CF_ACCOUNT_ID',           // 由 ctx.accountId 注入
  'MULTI_TENANT',            // 由 selfHosted 注入
  'KBDB_BASE_URL',           // 由 workerSubdomain 組
  'ARCRUN_BUNDLE_VERSION',   // 版本標籤：每趟重烙，**絕不沿用舊值**（見 resolveBundleStamp）
  'ARCRUN_BUNDLE_COMMIT',
] as const;

/** 烙版本標籤的那顆 worker（`/health` 就是它吐的）。其餘 worker 不需要版本標籤。 */
export const VERSION_STAMP_WORKER = 'arcrun-cypher-executor';

/** 這趟部署要烙上去的版本標籤。 */
export interface BundleStamp {
  /** 寫進 `ARCRUN_BUNDLE_VERSION`。 */
  version: string;
  /** 寫進 `ARCRUN_BUNDLE_COMMIT`（查得到才有）。 */
  commit?: string;
  /** 給人看的一句話（CLI 會印出來），說明這個版號是怎麼來的。 */
  note: string;
}

/**
 * 算「這趟部署上去的東西，該叫幾版」（Arcrun#106）。
 *
 * 🔴 為什麼**不是沿用實例上原本那個值**：那個值描述的是**當時裝上去的那份程式碼**。
 *   更新完程式碼換了，標籤沒換 = 一個永遠停在安裝當天的假標籤——比沒有標籤更糟，
 *   因為 leo 會拿它當「我驗收過了」。版本標籤是**成品的屬性**，不是使用者的設定，
 *   所以它是唯一一個「不沿用、每趟重烙」的 var（其餘 plain_text var 一律沿用，見 preservedVars）。
 *
 * 誠實邊界（mindset §7，這段要留著）：
 *   - CLI 部的是 `ARCRUN_REPO@ref` 的**原始碼**，發行版號（semver）是**安裝器頻道**在發的，
 *     兩者不是同一套編號。這裡取的是「部署當下該頻道公告的 release」，
 *     語義＝「我跟這個頻道的最新發行同源」，並**另外把真正的 commit 一起烙上去**
 *     （`ARCRUN_BUNDLE_COMMIT`／`/health` 的 `bundle_commit`）→ 有沒有漂掉，看 commit 就查得出來。
 *   - 查不到 release（離線／頻道掛了）→ **不猜、不掰**，退成 `YYYY-MM-DD+<commit7>` 這個
 *     舊實例本來就在用的格式。Portal 對非 semver 一律顯示成「較舊版本」——
 *     那正是我們想要的：**寧可說不準，也不要假裝已是最新**。
 */
export async function resolveBundleStamp(
  ref: string,
  commit?: string,
  fetchImpl: typeof fetch = fetch,
): Promise<BundleStamp> {
  const short = commit ? commit.slice(0, 7) : ref;
  const today = new Date().toISOString().slice(0, 10);
  try {
    const res = await fetchImpl(ARCRUN_RELEASE_API, { signal: AbortSignal.timeout(15_000) });
    if (!res.ok) throw new Error(`HTTP ${res.status}`);
    const body = (await res.json()) as { release?: string } | null;
    const release = String(body?.release ?? '').trim();
    if (!/^\d+\.\d+\.\d+$/.test(release)) throw new Error(`發行頻道回的版號不是 semver（${release || '空'}）`);
    return {
      version: release,
      commit,
      note: `${release}（發行頻道 ${ARCRUN_RELEASE_API}${commit ? `；實際部署 commit ${short}` : ''}）`,
    };
  } catch (e) {
    const version = `${today}+${short}`;
    return {
      version,
      commit,
      note:
        `${version}（查不到發行版號：${e instanceof Error ? e.message : String(e)}）` +
        `\n      → 誠實標成 commit 版；Portal 會顯示成「較舊版本」而不是假裝已是最新。`,
    };
  }
}

/**
 * 把 `ref`（branch / tag / sha）解析成確切的 commit sha（Arcrun#106）。
 *
 * 兩個用途：① 版本標籤要烙「真的部了哪個 commit」；② 解出來之後**直接用 sha 下載 archive**——
 * sha 是不可變的，順帶把 #13 P2 的「branch tarball 被中間層快取成舊的」整個病根拿掉。
 * 查不到就回 undefined（呼叫端退回原本的用 ref 下載，行為不變）——這條路徑不該讓更新失敗。
 */
export async function resolveGiteaCommit(
  ref: string,
  fetchImpl: typeof fetch = fetch,
): Promise<string | undefined> {
  const headers = buildDownloadHeaders();
  const tryUrls = [
    `${ARCRUN_GITEA_BASE}/api/v1/repos/${ARCRUN_REPO}/branches/${encodeURIComponent(ref)}`,
    `${ARCRUN_GITEA_BASE}/api/v1/repos/${ARCRUN_REPO}/commits?sha=${encodeURIComponent(ref)}&limit=1&stat=false`,
  ];
  for (const url of tryUrls) {
    try {
      const res = await fetchImpl(url, { headers, signal: AbortSignal.timeout(20_000) });
      if (!res.ok) continue;
      const body = (await res.json()) as
        | { commit?: { id?: string } }
        | Array<{ sha?: string }>
        | null;
      const sha = Array.isArray(body) ? body[0]?.sha : body?.commit?.id;
      if (typeof sha === 'string' && /^[0-9a-f]{7,64}$/i.test(sha)) return sha;
    } catch {
      /* 換下一種問法；全都問不到就回 undefined */
    }
  }
  return undefined;
}

/**
 * 組 Gitea archive 下載 URL（純函式，好離線測 URL 組裝）。
 * Gitea archive API：`GET {base}/api/v1/repos/{owner}/{repo}/archive/{ref}.tar.gz`。
 * bust：唯一 cache-buster query param（Gitea 對不同 query 視為不同請求 → 繞過任何中間快取；
 *   對齊原 codeload #13 P2 假綠防護，行為保留）。
 */
export function buildArchiveUrl(ref: string, bust: string, repo = ARCRUN_REPO, base = ARCRUN_GITEA_BASE): string {
  return `${base}/api/v1/repos/${repo}/archive/${ref}.tar.gz?_cb=${encodeURIComponent(bust)}`;
}

/**
 * 組下載用的 request headers（純函式，好離線測 token → header 對映）。
 * 帶 no-cache（保留 #13 P2 stale 防護）；有 token 才加 Gitea 慣用的 `Authorization: token <TOKEN>`
 *（private repo 需要；public repo 省略也可下載）。token 不寫死，來自 giteaToken()。
 */
export function buildDownloadHeaders(token = giteaToken()): Record<string, string> {
  const headers: Record<string, string> = { 'Cache-Control': 'no-cache', Pragma: 'no-cache' };
  if (token) headers.Authorization = `token ${token}`;
  return headers;
}

/**
 * arcrun 各 worker 會用到的 KV **binding 名**清單。
 *
 * 🔴 Arcrun#97 之後，這份清單**不再是「要去 CF 上建的資源標題」**——
 *   真正要哪些綁定，是部署當下從每份 wrangler.toml 讀出來的（parseWranglerRequirements），
 *   要不要建則由 resource-resolver 依「已部署的 worker 綁著什麼」決定。
 *   這裡保留成一份**文件與離線測試用的期望清單**（測試會比對 toml 沒有漏綁），
 *   不再被任何執行路徑拿去「照名字 ensure」。
 *
 * 原始出處保留如下：
 * 前 7 個權威來源：.claude/rules/01-tech-stack.md 資料儲存表（cypher-executor 用）。
 * SUBMISSIONS_KV：registry worker 用（component 投稿）。漏建會讓 registry deploy 失敗 →
 *   壓測 §2.6/#11「20/21」根因（registry/wrangler.toml 綁 SUBMISSIONS_KV，但注入清單沒有它，
 *   殘留官方舊 id → wrangler deploy 因 KV 不存在而失敗）。補進來後回到 21/21。
 * OAUTH_KV：arcrun-mcp worker 用（OAuth 2.1 server 的短效 authorization code + access token，
 *   帶 TTL、key 用 SHA-256 hash）。mcp/wrangler.toml 綁 OAUTH_KV，占位 id 由 injectWranglerConfig
 *   換成用戶帳號的真 id（比照上面同一套 title→binding 注入）。漏建 → mcp deploy 失敗（同 SUBMISSIONS_KV
 *   家族）。見 mcp/OAUTH.md §4/§7。
 */
export const REQUIRED_KV_NAMESPACES = [
  'WEBHOOKS',
  'CREDENTIALS_KV',
  'RECIPES',
  'USERS_KV',
  'SESSIONS_KV',
  'ANALYTICS_KV',
  'EXEC_CONTEXT',
  'SUBMISSIONS_KV',
  'OAUTH_KV',
] as const;

/** 共享部署依賴（downloadAndDeploy 2.5：tarball root 裝一次，各 worker 往上 resolve）。
 *  含全部 worker 的 runtime deps：tier1 component 只要 hono；tier2 cypher/registry/mcp/kbdb
 *  另需 zod / @hono/zod-openapi / @modelcontextprotocol/sdk / js-yaml / yaml；
 *  code 自足 Worker（registry/components/code）另需 quickjs-emscripten-core + wasmfile variant
 *  （版本對齊該零件 package.json，drift 由 cli/tests/deploy-code-component.test.ts 看守）。
 *  漏一個會讓該 worker deploy 失敗，故寧可多列。export 供離線測試驗清單完整。*/
export const SHARED_DEPLOY_DEPS: Record<string, string> = {
  hono: '^4.7.0',
  wrangler: '^4.0.0',
  zod: '^3.23.0',
  '@hono/zod-openapi': '^0.18.0',
  '@modelcontextprotocol/sdk': '^1.0.0',
  'js-yaml': '^4.1.0',
  yaml: '^2.4.0',
  'quickjs-emscripten-core': '^0.31.0',
  '@jitl/quickjs-wasmfile-release-sync': '^0.32.0',
};

export interface DeployContext {
  accountId: string;
  apiToken: string;
  workerSubdomain: string;
  /** binding → KV namespace id。**由 downloadAndDeploy 內部的資源解析填入，呼叫端不要自己給**
   *  （Arcrun#97：呼叫端「照名字 ensure 一輪再傳進來」正是把使用者實例洗空的那條路）。*/
  kvNamespaceIds?: Record<string, string>;
  /** KBDB Base D1 id；同上，由資源解析填入。*/
  d1DatabaseId?: string;
  // self-hosted 單租戶旗標。true（self-hosted）→ 注入 MULTI_TENANT="false" 到 worker [vars]，
  // 讓 MCP partner-auth 走 namespace 明碼分支（mcp-account-source §5.5）。
  // 未設 / false → 不注入（官方 SaaS 多租戶，行為不變）。
  selfHosted?: boolean;
  // 語義查詢開關（issue #7 / SDD T2.4）。true → 部署前建 CF Vectorize index 並注入 kbdb worker 的
  // [[vectorize]]+[ai] binding（取消 wrangler.toml 註解段）→ embed 模組啟用。未設/false → 不建、不注入，
  // base 維持 LIKE keyword（free-tier 友善）。
  kbdbEmbed?: boolean;
  /**
   * Arcrun#108：這台實例的知識命名空間（＝`~/.arcrun/config.yaml` 的 `api_key`），
   * 會寫進 cypher worker 的 `ARCRUN_NAMESPACE` var，讓「讀」用的 owner_id 與「寫」的一致。
   *
   * **只在驗證過該 namespace 底下真的有知識時才給值**（見 `resolveKnowledgeNamespace`）——
   * 給了就會覆蓋 worker 上的既有值，沒給則原封保留（preservedVars）。
   */
  knowledgeNamespace?: string;
}

/**
 * 這把 namespace 底下到底有沒有知識？（Arcrun#108 的「先驗再寫」）
 *
 * 打的是實例自己的 `GET /kbdb/map?owner_id=<ns>`（cypher 既有的純轉發端點，CLI 平常就在用
 * 這條路 + `X-Arcrun-API-Key`）。回傳：
 *   true  ＝ 這個 namespace 底下查得到庫 → 寫 ARCRUN_NAMESPACE 是安全的
 *   false ＝ 查得到但是空的 → 不寫（可能知識其實在別的命名空間，蓋下去會把畫面弄空）
 *   null  ＝ 問不到（實例還沒起來 / 舊版沒這條路 / 網路斷）→ 不寫，也不宣稱任何事
 *
 * 誠實邊界：這支只回答「有沒有」，不猜「應該是哪一個」。猜錯的代價是把人家的資料藏起來。
 */
export async function namespaceHasKnowledge(
  cypherUrl: string,
  namespace: string,
): Promise<boolean | null> {
  if (!cypherUrl || !namespace) return null;
  try {
    const res = await fetch(
      `${cypherUrl.replace(/\/+$/, '')}/kbdb/map?owner_id=${encodeURIComponent(namespace)}`,
      { headers: { 'X-Arcrun-API-Key': namespace } },
    );
    if (!res.ok) return null;
    const body = (await res.json().catch(() => null)) as { libraries?: unknown } | null;
    if (!body || !Array.isArray(body.libraries)) return null;
    return body.libraries.length > 0;
  } catch {
    return null;
  }
}

/**
 * Vectorize index 名（kbdb embed 模組用）。**bge-m3 = 1024 維、cosine。**
 *
 * 🔴 2026-08-03 換代（leo 拍板；5 組中文測資實證：舊 `bge-base-en-v1.5` 排序 2/5、
 *   margin −0.0413＝**中文根本不能用**；`bge-m3` 5/5、+0.1410、959ms）。
 *   leo 08-05：「換 embed model 當然要合併，當然要換 vectorize，原本的根本不能用」。
 *
 * **換模型必須換 index，且必須換「名字」**：
 *   ① 維度 768→1024，舊 index 收不進新向量
 *   ② 就算維度相同也不能沿用——不同模型的向量混在同一 index，比對出來是垃圾；
 *      而 #58（Vectorize vector delete 未接）代表舊向量刪不掉
 *      ⇒ **開新名字的 index 反而順手繞開 #58**，且新舊並存可回滾。
 *
 * ⚠️ 這個常數同時被 `ensureVectorizeMetadataIndexes()` 使用（deploy.ts:426）
 *   ⇒ t36 的四個 metadata index（owner_id/entry_type/source/library，Arcrun#11 根因修復）
 *   會自動建在新 index 上，**不會因為改名而遺失**（已查證，非假設）。
 *
 * 既有實例遷移：部署後 `POST /embed/backfill {"reindex":true}` 重嵌到 remaining=0，
 * 確認語意查詢正常後，舊的 `arcrun-kbdb-embed` 可自行刪除。
 */
export const KBDB_VECTORIZE_INDEX = 'arcrun-kbdb-embed-m3';

export interface DeployResult {
  implemented: boolean;
  cypherExecutorUrl?: string;
  mcpUrl?: string;            // self-hosted 自己的 MCP worker URL（mcp-account-source §3）
  message: string;
  /** true = 資源解析階段就喊停（Arcrun#97），**一顆資源沒建、一個 worker 沒部**。
   *  呼叫端要以非零結束並把 message 原文印出來，不要當成一般部分失敗帶過。*/
  blocked?: boolean;
  /** 這趟實際用上的資源（沿用/新建各是哪一顆）。呼叫端寫 config 用這個，不要自己再查一次。*/
  resources?: Map<string, ResolvedResource>;
}

/** 偵測 wrangler 是否已安裝（用戶前置：裝 CF CLI）。*/
export function wranglerAvailable(): boolean {
  try {
    execFileSync('wrangler', ['--version'], { stdio: 'ignore' });
    return true;
  } catch {
    return false;
  }
}

/**
 * 下載 repo archive tarball（含預編譯 wasm）→ 注入用戶 KV id → wrangler deploy 全部 Worker。
 *
 * SDD self-hosted-init.md §6.4：
 *   1. 下載 Gitea archive tarball（ref 預設 main）→ 解壓到暫存目錄
 *   2. 各 wrangler.toml 注入 ctx.kvNamespaceIds + cypher-executor WORKER_SUBDOMAIN
 *   3. tier1=.component-builds/* 先 → tier2=cypher-executor/registry 後，逐一 wrangler deploy
 *   4. 回 cypherExecutorUrl = https://arcrun-cypher-executor.<subdomain>.workers.dev
 *
 * 誠實（mindset §7）：任一 worker deploy 失敗會收集進 message 回報，不假裝全綠。
 *
 * @param ctx 部署上下文
 * @param ref git ref（branch / tag），預設 main；acr update 可帶 tag
 */
export async function downloadAndDeploy(
  ctx: DeployContext,
  ref = 'main',
  opts: { force?: boolean; mode?: 'init' | 'update'; api?: ResourceApi } = {},
): Promise<DeployResult> {
  const mode = opts.mode ?? 'update';
  const api = opts.api ?? new CfAccountClient(ctx.accountId, ctx.apiToken);
  // 1. 下載 + 解壓 Gitea archive tarball
  //    #106：先把 ref 解析成確切 commit，**用 sha 下載**（不可變 → 順帶解掉 branch tarball 被快取的老問題），
  //    同一個 sha 稍後也會被烙成版本標籤。解不出來就照舊用 ref 下載（行為不變）。
  const commit = await resolveGiteaCommit(ref);
  let root: string;
  try {
    root = await downloadRepoTarball(commit ?? ref, commit ? ref : undefined);
  } catch (e) {
    return {
      implemented: true,
      message: `下載部署物失敗（${e instanceof Error ? e.message : e}）。確認網路 + ARCRUN_REPO=${ARCRUN_REPO} 可達。`,
    };
  }

  // 2. 列出要部署的 worker 目錄（含 wrangler.toml），分 tier
  const { tier1, tier2 } = discoverWorkerDirs(root);
  if (tier1.length === 0 && tier2.length === 0) {
    return { implemented: true, message: `部署物中找不到任何 wrangler.toml（root=${root}）。` };
  }

  // 2.5 共享依賴：23 個 component worker 的 runtime dep 全是 hono、devDep 全含 wrangler，
  //     舊版每個 worker 各 install 一份 ~324MB node_modules（23× 重複，壓測 2026-06-12 慢的真因）。
  //     改成在 tarball root 裝「一次」hono+wrangler；component 目錄靠 node 往上 resolve（已驗證可行）。
  //     → 23×4.4s install 變 1×17s。失敗不致命：退回各 worker 自裝（runWranglerDeploy 仍有 fallback）。
  let sharedBin = '';
  try {
    process.stdout.write(chalk.gray('  → 安裝共享部署依賴（一次，取代每個 worker 各裝）...'));
    // 依賴清單抽出成 SHARED_DEPLOY_DEPS（export 供離線測試看守，見常數 doc）。
    writeFileSync(
      join(root, 'package.json'),
      JSON.stringify({ name: 'arcrun-deploy-shared', private: true, type: 'module',
        dependencies: SHARED_DEPLOY_DEPS }),
    );
    execFileSync('npm', ['install', '--no-audit', '--no-fund'],
      { cwd: root, stdio: ['ignore', 'ignore', 'pipe'] });
    sharedBin = join(root, 'node_modules', '.bin', 'wrangler');
    console.log(existsSync(sharedBin) ? chalk.green(' ✓') : chalk.yellow(' ⚠ 退回各 worker 自裝'));
    if (!existsSync(sharedBin)) sharedBin = '';
  } catch (e) {
    const tail = (e as { stderr?: Buffer }).stderr?.toString().trim().split('\n').slice(-2).join(' | ').slice(0, 200) ?? '';
    console.log(chalk.yellow(` ⚠ 共享安裝失敗，退回各 worker 自裝${tail ? `：${tail}` : ''}`));
  }

  const failures: string[] = [];
  const allDirs = [...tier1, ...tier2];

  // ── 2.6 資源解析：先看「這些 worker 現在綁著什麼」，再決定沿用還是新建（Arcrun#97）──────
  //
  // 🔴 這一段取代了舊的「照名字 ensure 一輪 KV/D1/Vectorize 再注入」。
  //    舊做法用 binding 名當資源標題去找，對不上就新建一顆空的綁上去——
  //    安裝器建的資源本來就不叫那個名字，於是**每次更新都對不上、每次都新建**：
  //    2026-08-12 一次更新生了 9 顆 KV + 1 顆 D1，使用者的工作流／登入／子庫全部從畫面上消失。
  //
  //    現在：已部署 worker 上的綁定＝事實，原樣沿用；只有「確定沒人綁過」才建；
  //    任何說不準的情況（讀不到綁定／綁著的資源不見了／同名綁定指向兩顆／一顆 worker 都找不到）
  //    → 整趟停手，**在動任何東西之前**。
  //
  //    需求是從「注入後的 toml」解析的（renderWranglerToml 帶空 map 當預覽），
  //    所以「解析看到的」和「最後寫進去的」保證是同一份檔案的同一種樣子。
  const requirements: BindingRequirement[] = [];
  const tomlPreviews = new Map<string, string>();   // dir → 注入前的原文
  const dirScript = new Map<string, string>();      // dir → worker script 名（#106：var 沿用要逐顆對號）
  for (const dir of allDirs) {
    const tomlPath = join(dir, 'wrangler.toml');
    if (!existsSync(tomlPath)) continue;
    const raw = readFileSync(tomlPath, 'utf8');
    tomlPreviews.set(dir, raw);
    const preview = renderWranglerToml(raw, ctx, new Map());
    const parsed = parseWranglerRequirements(preview);
    if (!parsed.script) continue;   // 沒宣告 name 的 toml 不該存在；跳過而非亂猜
    dirScript.set(dir, parsed.script);
    for (const b of parsed.bindings) {
      requirements.push({ ...b, worker: parsed.script });
    }
  }

  let resolved = new Map<string, ResolvedResource>();
  let liveVars = new Map<string, Record<string, string>>();
  if (requirements.length > 0) {
    process.stdout.write(chalk.gray('  → 對照你帳號上已部署的 worker，確認每個綁定該用哪顆資源...'));
    let plan;
    try {
      plan = await planResources(api, requirements, mode);
    } catch (e) {
      console.log(chalk.yellow(' ✗'));
      return {
        implemented: true,
        blocked: true,
        message:
          `資源解析失敗（${e instanceof Error ? e.message : String(e)}）。\n` +
          `沒有建立任何資源、沒有部署任何 worker——你現在的實例維持原樣。`,
      };
    }
    if (plan.blockers.length > 0) {
      console.log(chalk.yellow(' ✗'));
      return {
        implemented: true,
        blocked: true,
        message:
          `停手：有 ${plan.blockers.length} 件事我不敢自己決定。\n` +
          plan.blockers.map((b) => `  • ${b}`).join('\n') +
          `\n\n沒有建立任何資源、沒有部署任何 worker——你現在的實例維持原樣。`,
      };
    }
    try {
      resolved = await applyResourcePlan(api, plan);
    } catch (e) {
      console.log(chalk.yellow(' ✗'));
      const raw = e instanceof Error ? e.message : String(e);
      const detail = e instanceof ResourcePlanBlocked
        ? e.blockers.map((b) => `  • ${b}`).join('\n')
        : `  • ${raw}`;
      // D1 建不起來最常見的根因是 token 沒勾 D1 權限（KV/Worker 建得起來、只有 D1 報 auth error）。
      // 這句提示在改版前就有，別隨著搬家弄丟——它是使用者唯一能自己解掉的那個錯。
      const hint = /d1/i.test(raw) && /auth/i.test(raw)
        ? '\n  → CF token 缺 D1 權限：補勾「Account / D1 / Edit」重產 token 填回 .env 再跑一次。'
        : '';
      return {
        implemented: true,
        blocked: true,
        message: `停手：\n${detail}${hint}\n\n沒有部署任何 worker——你現在的實例維持原樣。`,
      };
    }
    liveVars = plan.liveVars;
    console.log(chalk.green(' ✓'));
    const adopted = [...resolved.values()].filter((r) => r.origin === 'adopted');
    const created = [...resolved.values()].filter((r) => r.origin === 'created');
    if (adopted.length > 0) {
      console.log(chalk.gray(`    沿用你既有的 ${adopted.length} 個資源（不論它們叫什麼名字）：`));
      for (const r of adopted) console.log(chalk.gray(`      = ${r.binding} → ${r.value}（讀自 ${r.from}）`));
    }
    if (created.length > 0) {
      console.log(chalk.yellow(`    新建 ${created.length} 個（目前沒有任何已部署的 worker 綁著它們）：`));
      for (const r of created) console.log(chalk.yellow(`      + ${r.binding} → ${r.value}`));
    }
  }

  // 解析結果回填 ctx，供 applyD1Migration / 呼叫端寫 config 使用。
  // KBDB 的 migration 打 kbdb worker 的 `DB`；沒有它才退回 cypher 的 `CREDENTIALS_DB`（同一顆庫）。
  ctx.kvNamespaceIds = Object.fromEntries(
    [...resolved.values()].filter((r) => r.kind === 'kv_namespace').map((r) => [r.binding, r.value]),
  );
  ctx.d1DatabaseId =
    resolved.get(bindingKey('d1', 'DB'))?.value
    ?? resolved.get(bindingKey('d1', 'CREDENTIALS_DB'))?.value;

  // 2.7 語義查詢（issue #7 / T2.4）：index 本體已由上面的資源解析處理（沿用既有 / 需要才新建）。
  //      這裡只補 metadata index——Vectorize 要 filter 某欄位必須先為該欄建 index，
  //      否則帶 owner_id/entry_type/source 過濾的語意查詢一律回 0 命中（Arcrun#11 根因）。
  //      冪等；失敗不致命（收進 failures，base 仍可部署、維持 keyword）。
  const vectorizeIndex = resolved.get(bindingKey('vectorize', 'VECTORIZE'))?.value;
  if (vectorizeIndex) {
    try {
      process.stdout.write(chalk.gray(`  → 語義查詢 metadata index（${vectorizeIndex}）...`));
      await ensureVectorizeMetadataIndexes(ctx, vectorizeIndex);
      console.log(chalk.green(' ✓'));
    } catch (e) {
      console.log(chalk.yellow(' ⚠'));
      failures.push(`Vectorize metadata index (${vectorizeIndex}): ${e instanceof Error ? e.message : String(e)}`);
    }
  }

  // ── 2.8 var（plain_text）：既有的沿用、版本標籤重烙（Arcrun#106）─────────────────
  //
  // 🔴 #97 修好了「櫃子」（KV/D1/Vectorize 沿用既有），但 **var 這批「櫃子上的標籤」沒人管**：
  //    wrangler deploy 是整份覆蓋，toml 沒寫的 var 直接消失。leo 2026-08-12 實撞的畫面
  //    「無法讀取目前版本（知識庫服務可能正在啟動）」就是 `ARCRUN_BUNDLE_VERSION` 被這樣洗掉的。
  //
  // 兩種 var 走**相反**的規則，這是本次的核心判斷：
  //    · 設定類（PORTAL_MAIL_RELAY_BASE / CONSOLE_TENANT / …）＝**使用者實例的事實** → 沿用
  //    · 版本標籤（ARCRUN_BUNDLE_VERSION）＝**這份成品的屬性** → 每趟重烙，沿用舊值就是假標籤
  //
  // 範圍註記：`liveVars` 來自資源解析那一趟讀到的 worker（＝有資源綁定的那些：cypher/kbdb/mcp/registry）。
  //   純零件 worker 沒有資源綁定、不在那份名單裡 → 這裡不會沿用它們的 var。目前它們的 var 只有
  //   toml 自己帶的 `COMPONENT_ID`，沒有東西可丟；若哪天有人往零件 worker 注入設定，要在這裡補讀。
  const extraVarsByDir = new Map<string, Record<string, string>>();
  let stamp: BundleStamp | undefined;
  if (dirScript.size > 0) {
    const needStamp = [...dirScript.values()].includes(VERSION_STAMP_WORKER);
    if (needStamp) {
      process.stdout.write(chalk.gray('  → 算這趟要烙上去的版本標籤...'));
      stamp = await resolveBundleStamp(ref, commit);
      console.log(chalk.green(' ✓'));
      console.log(chalk.gray(`    ARCRUN_BUNDLE_VERSION = ${stamp.note}`));
    }
    const preservedTotal: string[] = [];
    for (const [dir, script] of dirScript) {
      const raw = tomlPreviews.get(dir);
      if (!raw) continue;
      const keep = preservedVars(liveVars.get(script), raw);
      for (const k of Object.keys(keep)) preservedTotal.push(`${script}:${k}`);
      const vars: Record<string, string> = { ...keep };
      if (stamp && script === VERSION_STAMP_WORKER) {
        vars.ARCRUN_BUNDLE_VERSION = stamp.version;
        if (stamp.commit) vars.ARCRUN_BUNDLE_COMMIT = stamp.commit;
      }
      // Arcrun#108：把「你的知識實際住在哪個命名空間」告訴雲端。
      //
      // 為什麼需要：cypher 讀藏書地圖/搜尋/工作流時要用一個 owner_id 去過濾，而它以前拿的是
      // repo toml 帶的官方預設值（`CONSOLE_TENANT = "leo"`）。寫入端（CLI push、小幫手上傳、
      // MCP）用的卻是你 `~/.arcrun/config.yaml` 的 `api_key` ⇒ 兩邊對不上就整個空掉
      //（leo 實撞：1854 條三元組被過濾成 0 個庫）。
      //
      // 🔴 **只在「這個 namespace 底下真的查得到知識」時才寫**（呼叫端已先驗過，見
      //    resolveKnowledgeNamespace）。理由是反過來的那個災難：一鍵安裝的實例，知識可能
      //    本來就寫在 CONSOLE_TENANT 底下；若這裡無條件蓋成本機 api_key，會把一台**原本正常**
      //    的實例改成指向空的那一格——跟 #97/#106 同一類「更新一次把人家的東西弄不見」。
      //    驗不過就不寫；既有值由 preservedVars 原封保留，等於這趟什麼都沒改。
      if (ctx.knowledgeNamespace && script === VERSION_STAMP_WORKER) {
        vars.ARCRUN_NAMESPACE = ctx.knowledgeNamespace;
      }
      if (Object.keys(vars).length > 0) extraVarsByDir.set(dir, vars);
    }
    if (preservedTotal.length > 0) {
      console.log(chalk.gray(`    沿用你實例上既有的 ${preservedTotal.length} 個設定值（var）：`));
      for (const item of preservedTotal) console.log(chalk.gray(`      = ${item}`));
    }
  }

  // 3. 對每個 worker：注入 KV id（+ cypher WORKER_SUBDOMAIN）→ wrangler deploy。tier1 先 tier2 後。
  // 逐 worker 串流進度（每個含 pnpm install + wrangler deploy，沉默會讓人以為卡住——
  //   壓測 2026-06-11 richblack 觀察：「D1 ✓」後停很久其實在這個迴圈靜默部署 20+ worker）。
  let deployed = 0;
  let skipped = 0;
  // 內容指紋 manifest：未變動且上次成功的 worker 跳過（key 用 worker 名，不用 temp 絕對路徑）。
  // --force 清空 manifest → 全部重部。
  const manifest = opts.force ? {} : loadManifest();
  console.log(chalk.gray(`  → 部署 ${allDirs.length} 個 worker（未變動者跳過，依序進行）...`));
  for (let i = 0; i < allDirs.length; i++) {
    const dir = allDirs[i];
    const tomlPath = join(dir, 'wrangler.toml');
    const label = dir.replace(/^.*\.component-builds\//, '').replace(/^.*\//, '');
    process.stdout.write(chalk.gray(`    [${i + 1}/${allDirs.length}] ${label} ...`));
    try {
      injectWranglerConfig(tomlPath, ctx, resolved, tomlPreviews.get(dir), extraVarsByDir.get(dir));
      // 注入後算指紋：與 manifest 比，相同 = 上次成功部過且內容沒變 → 跳過。
      const hash = dirContentHash(dir, ctx.accountId);
      if (manifest[label] === hash) {
        skipped++;
        console.log(chalk.gray(' ⊘ 未變動，跳過'));
        continue;
      }
      runWranglerDeploy(dir, ctx, sharedBin);
      manifest[label] = hash;        // 只在成功後記錄 → 失敗者下次必重試
      saveManifest(manifest);
      deployed++;
      console.log(chalk.green(' ✓'));
    } catch (e) {
      delete manifest[label];        // 失敗 → 清掉舊指紋，確保下次重部
      saveManifest(manifest);
      failures.push(`${dir}: ${e instanceof Error ? e.message : String(e)}`);
      console.log(chalk.yellow(' ⚠'));
    }
  }
  if (skipped > 0) {
    console.log(chalk.gray(`    （${skipped} 個未變動已跳過；要強制全部重部跑 acr update --force）`));
  }

  // 3.5 KBDB Base: D1 建好後套 migration（建三表 + recipe_stat seed）。
  //     建 D1（cf-api ensureD1Database）只產生空資料庫，schema 要靠這步套。
  //     migration 檔來自同一份 tarball（root/kbdb/migrations/0001_base.sql），與 wasm 同源，
  //     不依賴本地 CLI 安裝路徑。0001_base.sql 全用 IF NOT EXISTS / INSERT OR IGNORE → 可重複套（idempotent）。
  if (ctx.d1DatabaseId) {
    const migPath = join(root, 'kbdb', 'migrations', '0001_base.sql');
    if (existsSync(migPath)) {
      try {
        await applyD1Migration(ctx, readFileSync(migPath, 'utf8'));
      } catch (e) {
        failures.push(`D1 migration (${ctx.d1DatabaseId}): ${e instanceof Error ? e.message : String(e)}`);
      }
    } else {
      failures.push(`D1 migration: 部署物缺 kbdb/migrations/0001_base.sql（${migPath}）`);
    }

    // 3.6 credential template seed（D38 圍牆修復，總管交辦，2026-08-07）：credential 目錄改走
    //     KBDB template 機制（entries 表 entry_type='credential'，比照 recipe_stat/execution_log
    //     慣例），取代舊的獨立 credentials 表（0002，已退役，見該檔頭部說明）。冪等，套用機制
    //     與 0001_base.sql 完全相同。密文本體仍住 Workers per-script Secrets（見
    //     cypher-executor/src/routes/credentials.ts），D19「擁有目錄不擁有內容物」不變。
    const credTplMigPath = join(root, 'kbdb', 'migrations', '0005_credential_template.sql');
    if (existsSync(credTplMigPath)) {
      try {
        await applyD1Migration(ctx, readFileSync(credTplMigPath, 'utf8'));
      } catch (e) {
        failures.push(`D1 migration 0005_credential_template (${ctx.d1DatabaseId}): ${e instanceof Error ? e.message : String(e)}`);
      }
    } else {
      failures.push(`D1 migration: 部署物缺 kbdb/migrations/0005_credential_template.sql（${credTplMigPath}）`);
    }

    // 3.6b 退役舊 credentials 表（D38，2026-08-07）：把該表殘留資料（若有）搬進 entries 後
    //     拆表，讓 KBDB 回到「只有三張核心表」的狀態。冪等且對「從未跑過 0002」的全新實例
    //     無害（表不存在時本檔第一步先補空殼再立刻拆掉，詳見檔頭）。每次部署都會重跑，
    //     但真資料只搬一次（NOT EXISTS 判斷防重複）。
    const dropCredMigPath = join(root, 'kbdb', 'migrations', '0006_drop_credentials_table.sql');
    if (existsSync(dropCredMigPath)) {
      try {
        await applyD1Migration(ctx, readFileSync(dropCredMigPath, 'utf8'));
      } catch (e) {
        failures.push(`D1 migration 0006_drop_credentials_table (${ctx.d1DatabaseId}): ${e instanceof Error ? e.message : String(e)}`);
      }
    } else {
      failures.push(`D1 migration: 部署物缺 kbdb/migrations/0006_drop_credentials_table.sql（${dropCredMigPath}）`);
    }

    // 3.7 execution_log template seed（KV 額度事故修復，2026-08-07）：workflow 執行紀錄改走
    //     KBDB template 機制（entries 表 entry_type='execution_log'，比照 recipe_stat 慣例；
    //     schema 零異動，只 seed 一列 template 定義，同 0001_base.sql §3 手法，self-hosted 同步套用）。
    const execLogMigPath = join(root, 'kbdb', 'migrations', '0004_execution_log_template.sql');
    if (existsSync(execLogMigPath)) {
      try {
        await applyD1Migration(ctx, readFileSync(execLogMigPath, 'utf8'));
      } catch (e) {
        failures.push(`D1 migration 0004_execution_log_template (${ctx.d1DatabaseId}): ${e instanceof Error ? e.message : String(e)}`);
      }
    } else {
      failures.push(`D1 migration: 部署物缺 kbdb/migrations/0004_execution_log_template.sql（${execLogMigPath}）`);
    }
  }

  const cypherExecutorUrl = ctx.workerSubdomain
    ? `https://arcrun-cypher-executor.${ctx.workerSubdomain}.workers.dev`
    : undefined;
  // self-hosted 自己的 MCP worker URL（mcp-account-source §3：.mcp.json 指自己）。
  // 端點是 /mcp（streamable http；根路徑 404）。仿 cypher 用 WORKER_SUBDOMAIN 組。
  const mcpUrl = ctx.workerSubdomain
    ? `https://arcrun-mcp.${ctx.workerSubdomain}.workers.dev/mcp`
    : undefined;

  if (failures.length > 0) {
    return {
      implemented: true,
      cypherExecutorUrl,
      mcpUrl,
      message:
        `部署 ${deployed}/${tier1.length + tier2.length} 成功，${failures.length} 失敗（誠實回報，未假綠）：\n` +
        failures.map(f => `  ✗ ${f}`).join('\n'),
    };
  }

  return {
    implemented: true,
    cypherExecutorUrl,
    mcpUrl,
    message: `部署完成：${deployed} 個 Worker 全部成功。`,
  };
}

/**
 * 對 D1 套 SQL migration（透過 CF API `/d1/database/{id}/query`，非 wrangler）。
 * 用 init 已驗的 ctx.apiToken + accountId；query 端點接受多語句檔，一次送整份 0001_base.sql。
 */
async function applyD1Migration(ctx: DeployContext, sql: string): Promise<void> {
  const url = `https://api.cloudflare.com/client/v4/accounts/${ctx.accountId}/d1/database/${ctx.d1DatabaseId}/query`;
  const res = await fetch(url, {
    method: 'POST',
    headers: {
      Authorization: `Bearer ${ctx.apiToken}`,
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({ sql }),
    signal: AbortSignal.timeout(60_000),
  });
  const json = (await res.json().catch(() => null)) as
    | { success?: boolean; errors?: Array<{ message?: string }> }
    | null;
  if (!res.ok || !json?.success) {
    const detail = json?.errors?.map(e => e.message).filter(Boolean).join('; ') || `HTTP ${res.status}`;
    throw new Error(detail);
  }
}

/** embed 過濾用的 Vectorize metadata index 欄位（型別 string；對齊 embedOnWrite 寫入的 metadata）。 */
export const KBDB_VECTORIZE_META_FIELDS = ['owner_id', 'entry_type', 'source'] as const;

/**
 * 確保 KBDB embed index 上的 metadata index（owner_id/entry_type/source）存在（Arcrun#11 根因修復）。
 * Vectorize v2：要對某 metadata 欄位下 filter，必須先為該欄建 metadata index，否則帶過濾的語意查詢一律回 0。
 * REST `POST /accounts/{id}/vectorize/v2/indexes/{index}/metadata_index/create`（indexType=string）。
 * 冪等：已存在（409 / already exists）視為成功。async 生效（建立後才 upsert 的向量才會被收錄 → 既有向量另需 reindex）。
 *
 * 🔴 index 名由呼叫端傳入（= 資源解析沿用到的那顆），**不是**寫死 KBDB_VECTORIZE_INDEX：
 *   使用者實例上那顆 index 叫什麼是他那側的事實，我們把 metadata index 建到「他真的在用的那顆」上。
 */
async function ensureVectorizeMetadataIndexes(ctx: DeployContext, indexName: string): Promise<void> {
  const url = `https://api.cloudflare.com/client/v4/accounts/${ctx.accountId}/vectorize/v2/indexes/${indexName}/metadata_index/create`;
  for (const propertyName of KBDB_VECTORIZE_META_FIELDS) {
    const res = await fetch(url, {
      method: 'POST',
      headers: { Authorization: `Bearer ${ctx.apiToken}`, 'Content-Type': 'application/json' },
      body: JSON.stringify({ propertyName, indexType: 'string' }),
      signal: AbortSignal.timeout(60_000),
    });
    if (res.ok) continue;
    const json = (await res.json().catch(() => null)) as
      | { success?: boolean; errors?: Array<{ message?: string; code?: number }> }
      | null;
    const msg = (json?.errors?.map(e => e.message).filter(Boolean).join('; ') || `HTTP ${res.status}`).toLowerCase();
    if (res.status === 409 || /already exists|duplicate|conflict/.test(msg)) continue;
    throw new Error(`metadata_index ${propertyName}: ${msg}`);
  }
}

/** 下載 Gitea archive tarball 解壓到暫存目錄，回傳解壓出的 repo root 路徑。
 *
 * ⚠️ Arcrun#13 P2 根因防護（沿用）：branch archive（archive/main.tar.gz）可能被中間層快取，
 * push 後該 ref 的 tarball 可能 stale 數分鐘。「push → 立刻 acr update」會抓到舊 tarball →
 * wrangler deploy 仍回 ✓（部署成功）但 ship 的是**舊 code**（假綠：「deploy 成功」≠「部到修好的版本」）。
 * 解法：fetch 時帶 no-cache header + 唯一 query param 強制繞過快取，每次抓到 ref 的最新內容。
 *
 * Arcrun#4：來源由 GitHub codeload 改為 Gitea archive API（走 GITEA_TOKEN，不寫死）。*/
async function downloadRepoTarball(ref: string, fromRef?: string): Promise<string> {
  // 唯一 cache-buster query param：對不同 query 視為不同請求 → 繞過 stale 快取。
  const bust = `${Date.now()}-${Math.random().toString(36).slice(2)}`;
  const url = buildArchiveUrl(ref, bust);
  // fromRef 有值 = ref 已被解析成 commit sha（#106），印出來讓人看得到「這趟到底部了哪個 commit」。
  const label = fromRef ? `${fromRef} → ${ref.slice(0, 7)}` : ref;
  console.log(chalk.gray(`  → 從 Gitea 下載最新版本（${ARCRUN_REPO}@${label}，約 10–30 秒，視網速）...`));
  const res = await fetch(url, {
    signal: AbortSignal.timeout(120_000),
    // 強制繞過任何中間快取，避免抓到 push 後尚未刷新的 stale tarball（#13 P2 假綠根因）。
    // 帶 Gitea token（若有，private repo 需要；public 省略也可）。
    headers: buildDownloadHeaders(),
    cache: 'no-store',
  });
  if (!res.ok) {
    // 401/403 多半是 private repo 缺 token（或 token 無此 repo 讀權限）→ 給可行動的提示。
    const hint = (res.status === 401 || res.status === 403)
      ? '（private repo？請設 GITEA_TOKEN 環境變數，需對此 repo 有讀取權限）'
      : '';
    throw new Error(`Gitea archive HTTP ${res.status}${hint}（${url}）`);
  }

  const buf = Buffer.from(await res.arrayBuffer());
  const sizeMB = (buf.length / 1024 / 1024).toFixed(1);
  console.log(chalk.gray(`  → 下載完成（${sizeMB} MB），解壓中...`));
  const dir = mkdtempSync(join(tmpdir(), 'arcrun-deploy-'));
  const tarPath = join(dir, 'repo.tar.gz');
  writeFileSync(tarPath, buf);

  // 用系統 tar 解壓（macOS/Linux 內建）。tarball 解出單一頂層目錄 {repo}-{ref}/。
  execFileSync('tar', ['-xzf', tarPath, '-C', dir], { stdio: 'ignore' });
  const entries = readdirSync(dir).filter(n => n !== 'repo.tar.gz');
  const top = entries.find(n => statSync(join(dir, n)).isDirectory());
  if (!top) throw new Error('tarball 解壓後找不到頂層目錄');
  return join(dir, top);
}

/** 自足 Worker 零件（非 TinyGo-wasm 家族）：目錄相對 root + 部署 gate 必要產物（相對該目錄）。
 *  目前只有 code（quickjs 沙箱，registry/components/code）。gate 精神比照 tier1 的 component.wasm：
 *  必要產物（vendored quickjs.wasm，需 commit 進 repo）缺 → 誠實跳過，不讓 wrangler deploy 因缺檔失敗。
 *  export 供離線測試驗「部署清單含 code + 產物 gate 正確」。*/
export const SELF_CONTAINED_COMPONENT_WORKERS: ReadonlyArray<{ dir: string[]; requires: string[][] }> = [
  { dir: ['registry', 'components', 'code'], requires: [['vendor', 'quickjs.wasm']] },
];

/** 掃解壓出的部署物，回傳 tier1（.component-builds/* + 自足 Worker 零件）與
 *  tier2（cypher-executor/registry/kbdb/mcp 引擎）目錄清單。export 供離線測試。*/
export function discoverWorkerDirs(root: string): { tier1: string[]; tier2: string[] } {
  const tier1: string[] = [];
  const tier2: string[] = [];

  const cbRoot = join(root, '.component-builds');
  if (existsSync(cbRoot)) {
    for (const name of readdirSync(cbRoot)) {
      const dir = join(cbRoot, name);
      // 需同時有 wrangler.toml 且有 component.wasm 才部署。
      // 「錯做成零件」的（claude_api / km_writer / kbdb_upsert_block）wasm 沒 commit 進 repo
      // （.gitignore 排除，待降級成工作流/recipe）→ archive 拿到的目錄缺 wasm → 自然跳過，
      // 不讓 wrangler deploy 因缺檔失敗。
      if (existsSync(join(dir, 'wrangler.toml')) && existsSync(join(dir, 'component.wasm'))) {
        tier1.push(dir);
      }
    }
  }
  // 自足 Worker 零件（如 code）：與 TinyGo 家族不同（自帶 index.ts + 相依 npm 套件 +
  // vendored quickjs.wasm），但同屬 tier1「零件」語義 → 一起先於引擎部署。
  // deps（quickjs-emscripten-core / wasmfile variant）由 root 共享安裝提供（SHARED_DEPLOY_DEPS），
  // wrangler 對相對路徑 .wasm import 自動綁 CompiledWasm（見該零件 index.ts 頭註）。
  for (const { dir: rel, requires } of SELF_CONTAINED_COMPONENT_WORKERS) {
    const dir = join(root, ...rel);
    const complete = existsSync(join(dir, 'wrangler.toml'))
      && requires.every(r => existsSync(join(dir, ...r)));
    if (complete) tier1.push(dir);
  }
  // self-hosted 也部署自己的 MCP worker（mcp-account-source §5c：archive 主庫即得 MCP，
  // .mcp.json 指自己的 mcp 而非官方 mcp.arcrun.dev）。
  // kbdb：MCP 的 partnerAuthMiddleware 透過 KBDB service binding 打 arcrun-kbdb worker（mcp/wrangler.toml）。
  //   D1 arcrun-kbdb 已由 init/update 建好，但 worker 本體要一併部署，否則 binding 指向不存在的 service
  //   → 每個 MCP 認證請求都 throw（self-hosted MCP failed 根因，2026-06-10）。
  for (const name of ['cypher-executor', 'registry', 'kbdb', 'mcp']) {
    const dir = join(root, name);
    if (existsSync(join(dir, 'wrangler.toml'))) tier2.push(dir);
  }
  return { tier1, tier2 };
}

/**
 * 注入用戶的 KV namespace id（取代 wrangler.toml 中各 binding 的 id）+ cypher WORKER_SUBDOMAIN，
 * 並 strip 掉只有 arcrun 官方帳號才有的綁定（self-hosted fork 帳號沒有）。
 *
 * 為何 strip 而非刪 repo 內 toml（壓測 2026-06-04 阻斷項 #1#2#3#4）：
 *   - repo 內各 worker toml 的 `[[routes]] zone_name="arcrun.dev"` 是**官方 prod CI 部署**需要的
 *     （對外開放零件）。直接從 repo 刪會破壞官方部署。
 *   - 但 fork 用戶**沒有 arcrun.dev zone** → wrangler deploy 找不到 zone 而失敗。
 *   - deploy.ts 只在 self-hosted 路徑跑，且改的是「暫存目錄副本」（SDD self-hosted-init.md §3 step 4），
 *     不碰用戶 repo。所以在注入時 strip 掉這些官方專屬綁定 = 對的層級。
 *   - 每個 worker toml 都有 `workers_dev = true` → strip routes 後純靠 workers.dev URL，自架可達。
 *   - R2（`[[r2_buckets]]`）是 dead storage（registry-canon Phase 1.5），且綁卡違背開源免費 → 一併移除。
 */
function injectWranglerConfig(
  tomlPath: string,
  ctx: DeployContext,
  resolved: Map<string, ResolvedResource>,
  original?: string,
  extraVars: Record<string, string> = {},
): void {
  if (!existsSync(tomlPath)) return;
  // original = 資源解析階段讀到的原文。用它而不是重讀檔案，確保「解析看到的」與「寫回去的」同源。
  const toml = original ?? readFileSync(tomlPath, 'utf8');
  writeFileSync(tomlPath, renderWranglerToml(toml, ctx, resolved, extraVars), 'utf8');
}

/**
 * 挑出「這顆已部署的 worker 上有、但這版 toml 不會自己帶的」plain_text var（Arcrun#106）。
 *
 * 規則就一句：**已部署 worker 上掛著什麼 var，那就是事實**（#97 對資源講的那句話，
 * 原封不動套用在標籤上）。所以預設全部沿用，只有兩種例外：
 *   ① `CLI_MANAGED_VARS`——這趟由 CLI 自己算（帳號 id／subdomain／單租戶旗標／版本標籤），
 *      沿用等於拿舊值蓋掉正解。
 *   ② 值一模一樣的（toml 已經寫了同樣的值）——寫進去只是雜訊，略過。
 *
 * ⚠️ 這裡刻意**不**做「toml 有宣告就以 toml 為準」：那正是這次的病
 * ——repo toml 裡的 `CONSOLE_TENANT = "leo"`／`WORKER_SUBDOMAIN` 之類是**官方 prod 的值**，
 * 拿它蓋掉使用者實例上的值，就是「更新一次把人家的設定洗成官方預設」。
 */
export function preservedVars(
  live: Record<string, string> | undefined,
  toml: string,
): Record<string, string> {
  const out: Record<string, string> = {};
  if (!live) return out;
  const managed = new Set<string>(CLI_MANAGED_VARS);
  for (const key of Object.keys(live).sort()) {
    if (managed.has(key)) continue;
    if (!/^[A-Za-z0-9_]+$/.test(key)) continue;      // 怪名字不碰（applyVars 也會擋，這裡先濾掉不誤報）
    if (readVar(toml, key) === live[key]) continue;   // toml 已經是同一個值 → 不必動
    out[key] = live[key];
  }
  return out;
}

/** 讀 toml 裡某個 var 目前的值（只看未註解的行）。找不到回 undefined。 */
function readVar(toml: string, key: string): string | undefined {
  const m = toml.match(new RegExp(`^\\s*${key}\\s*=\\s*"([^"]*)"`, 'm'));
  return m?.[1];
}

/** TOML basic string 轉義（值裡可能有引號／反斜線，例如網址或 JSON 片段）。 */
function tomlEscape(value: string): string {
  return value.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
}

/**
 * 把一組 var 寫進 toml 的 `[vars]`（Arcrun#106）。純函式。
 *
 * 三種既有狀態各自處理（比照 injectMultiTenant，同一種文字操作層級）：
 *   1. 已有未註解的同名行 → 換值
 *   2. 只有被註解掉的同名行 → 取消註解並填值
 *   3. 都沒有 → 插在 `[vars]` header 下一行；連 `[vars]` 都沒有就在檔尾新開一段
 */
export function applyVars(toml: string, vars: Record<string, string>): string {
  let out = toml;
  for (const key of Object.keys(vars).sort()) {
    // 只接受合法的 var 名（CF 那側本來就是這個字集）。怪名字寧可不寫，也不要拿它去組正規式。
    if (!/^[A-Za-z0-9_]+$/.test(key)) continue;
    const value = tomlEscape(vars[key]);
    // 🔴 一律用「函式版 replace」：值裡若有 `$&`／`$1` 這種字元，字串版 replace 會把它當成
    //   反向參照展開，寫出來的就不是使用者那個值了。
    if (new RegExp(`^\\s*${key}\\s*=`, 'm').test(out)) {
      out = out.replace(
        new RegExp(`^(\\s*${key}\\s*=\\s*")[^"]*(".*)$`, 'm'),
        (_m, head: string, tail: string) => `${head}${value}${tail}`,
      );
      continue;
    }
    if (new RegExp(`^\\s*#\\s*${key}\\s*=`, 'm').test(out)) {
      out = out.replace(
        new RegExp(`^(\\s*)#\\s*${key}\\s*=\\s*"[^"]*"(.*)$`, 'm'),
        (_m, indent: string, tail: string) => `${indent}${key} = "${value}"${tail}`,
      );
      continue;
    }
    if (/^\s*\[vars\]\s*$/m.test(out)) {
      out = out.replace(/^(\s*\[vars\]\s*)$/m, (_m, header: string) => `${header}\n${key} = "${value}"`);
      continue;
    }
    out = `${out.replace(/\s*$/, '')}\n\n[vars]\n${key} = "${value}"\n`;
  }
  return out;
}

/**
 * 把一份 repo 內的 wrangler.toml 轉成「要部到這個用戶帳號上的樣子」。
 *
 * 純函式（好離線測、也好當預覽用）。帶空 `resolved` 呼叫 = 預覽：得到的是
 * 「除了資源 id 以外都已經定案」的 toml，資源解析就是照這份預覽去數需求的
 * ⇒ 解析階段看到的 binding 清單，與最後真的寫進檔案的，保證一致（Arcrun#97 的教訓：
 *   兩段程式對同一份檔案有不同想像，就會出現「以為沒有、其實有」）。
 *
 * `extraVars`（Arcrun#106）：這顆 worker 要**沿用的既有 var** ＋ 這趟要**重烙的版本標籤**。
 *   預覽時不傳（vars 不影響資源需求解析，傳不傳都是同一份需求清單）。
 */
export function renderWranglerToml(
  toml: string,
  ctx: DeployContext,
  resolved: Map<string, ResolvedResource>,
  extraVars: Record<string, string> = {},
): string {
  // cypher-executor 的 WORKER_SUBDOMAIN（vars）換成用戶帳號 subdomain
  if (ctx.workerSubdomain && /WORKER_SUBDOMAIN/.test(toml)) {
    toml = toml.replace(
      /(WORKER_SUBDOMAIN\s*=\s*")[^"]*(")/,
      `$1${ctx.workerSubdomain}$2`,
    );
  }

  // credential-store-migration T3：CF_ACCOUNT_ID（vars，非機密識別碼）換成用戶自己的帳號 id，
  // 比照 WORKER_SUBDOMAIN 注入同一套機制。cypher 寫入 /credentials 時要用它組
  // CF Workers Scripts secrets 管理 API URL（見 routes/credentials.ts）。
  if (ctx.accountId && /CF_ACCOUNT_ID/.test(toml)) {
    toml = toml.replace(
      /(CF_ACCOUNT_ID\s*=\s*")[^"]*(")/,
      `$1${ctx.accountId}$2`,
    );
  }

  // self-hosted：注入 MULTI_TENANT="false" 到 [vars]（mcp-account-source §5.5）。
  // 修「部署沒注入 → worker c.env.MULTI_TENANT===undefined → MCP 走 partner-key → 401」。
  // 只對有 [vars] 的 worker（mcp / cypher-executor）生效；其餘無 [vars] 的不動。
  if (ctx.selfHosted) {
    toml = injectMultiTenant(toml);

    // self-hosted：把 cypher 的 KBDB_BASE_URL 從官方 arcrun-kbdb.uncle6-me 改成用戶自己帳號的
    // arcrun-kbdb.<subdomain>.workers.dev（issue #2）。比照 database_id / MULTI_TENANT 注入模式。
    // 漏這一個 → cypher /kbdb/* fallback 到官方 kbdb worker，self-hosted 資料寫進官方庫（隔離破損）。
    if (ctx.workerSubdomain) {
      toml = toml.replace(
        /(KBDB_BASE_URL\s*=\s*")[^"]*(")/,
        `$1https://arcrun-kbdb.${ctx.workerSubdomain}.workers.dev$2`,
      );
    }
  }

  toml = stripOfficialOnlyBindings(toml);

  // 語義查詢（issue #7 / T2.4）：開 kbdb_embed → 取消 kbdb toml 的 [[vectorize]]+[ai] 註解段（注入 active binding）。
  // **必須在 stripOfficialOnlyBindings 之後**：strip 會移除 [ai] 區塊（官方專屬），若先注入會被它清掉。
  // 只對含該註解段的 toml（= kbdb）生效；其餘 worker toml 無此段，replace 不命中、不動。
  // 未開 → 維持註解 → worker env 無 VECTORIZE/AI → embedEnabled()=false → base keyword（不花費）。
  if (ctx.kbdbEmbed) {
    toml = toml.replace(
      /# (\[\[vectorize\]\])\n# (binding = "VECTORIZE")\n# (index_name = "[^"]*")/,
      '$1\n$2\n$3',
    );
    toml = toml.replace(/# (\[ai\])\n# (binding = "AI")/, '$1\n$2');
  }

  // 沿用的既有 var ＋ 這趟的版本標籤（#106）。**放在所有 CLI 注入之後**：
  // CLI_MANAGED_VARS 已經在 preservedVars 排除掉，故這裡不會蓋掉上面剛算好的
  // WORKER_SUBDOMAIN / CF_ACCOUNT_ID / MULTI_TENANT / KBDB_BASE_URL。
  toml = applyVars(toml, extraVars);

  // 資源 id 一律最後注入，且**照 binding 名逐個對號**（不是「檔案裡第一個 database_id」那種盲換）。
  // 空 map = 預覽模式，這步什麼也不做。
  return applyResolvedBindings(toml, resolved);
}

/**
 * 把解析好的資源 id 寫進對應的 binding 區塊。
 *
 * 逐個 `[[table]]` 區塊掃：先在區塊內找 `binding = "X"`，再改同一區塊裡的值欄位
 * （KV→`id`、D1→`database_id`、Vectorize→`index_name`）。
 * 🔴 刻意**不用**「全檔第一個 database_id」這種寫法：cypher（`CREDENTIALS_DB`）與
 *   kbdb（`DB`）各有自己的 D1 綁定，盲換會把兩邊當成同一個東西——而使用者的實例
 *   完全可以兩邊指向不同庫。誰綁誰是使用者那側的事實，我們只是原樣搬過去。
 */
export function applyResolvedBindings(
  toml: string,
  resolved: Map<string, ResolvedResource>,
): string {
  if (resolved.size === 0) return toml;

  const VALUE_KEY: Record<ResourceKind, string> = {
    kv_namespace: 'id',
    d1: 'database_id',
    vectorize: 'index_name',
  };

  const out: string[] = [];
  let block: string[] = [];
  let kind: ResourceKind | null = null;

  const flush = (): void => {
    if (kind) {
      const binding = block
        .map((l) => l.trim())
        .filter((l) => !l.startsWith('#'))
        .map((l) => l.match(/^binding\s*=\s*"([^"]*)"/)?.[1])
        .find((b): b is string => !!b);
      const hit = binding ? resolved.get(bindingKey(kind, binding)) : undefined;
      if (hit) {
        const key = VALUE_KEY[kind];
        const re = new RegExp(`^(\\s*${key}\\s*=\\s*")[^"]*(")(.*)$`);
        const at = block.findIndex((l) => !l.trim().startsWith('#') && re.test(l));
        if (at >= 0) {
          block[at] = block[at].replace(re, `$1${hit.value}$2$3`);
        } else {
          // 區塊裡本來沒有這個欄位（例如新版 toml 只寫 binding）→ 補一行，不要靜默略過。
          block.push(`${key} = "${hit.value}"`);
        }
      }
    }
    out.push(...block);
    block = [];
  };

  for (const line of toml.split('\n')) {
    const table = line.trim().match(/^\[\[?([A-Za-z0-9_]+)\]?\]$/);
    if (table) {
      flush();
      kind = TABLE_KIND[table[1]] ?? null;
    }
    block.push(line);
  }
  flush();

  return out.join('\n');
}

/**
 * self-hosted：確保 worker [vars] 有 `MULTI_TENANT = "false"`。處理三種既有狀態：
 *   1. 已有 active `MULTI_TENANT = "..."` → 改成 "false"
 *   2. 有註解的 `# MULTI_TENANT = "false"`（mcp/cypher toml 預設這樣）→ 取消註解
 *   3. 無此行但有 `[vars]` → 在 [vars] header 下一行加進去
 *   4. 無 `[vars]`（該 worker 不吃此 var）→ 不動
 * 純文字操作，與 WORKER_SUBDOMAIN/KV 注入同層級（mcp-account-source §5.5）。
 */
export function injectMultiTenant(toml: string): string {
  // 1. 已有 active 行 → 設 false
  if (/^\s*MULTI_TENANT\s*=/m.test(toml)) {
    return toml.replace(/^(\s*MULTI_TENANT\s*=\s*")[^"]*(".*)$/m, `$1false$2`);
  }
  // 2. 註解掉的行 → 取消註解（保留原縮排）
  if (/^\s*#\s*MULTI_TENANT\s*=/m.test(toml)) {
    return toml.replace(/^(\s*)#\s*(MULTI_TENANT\s*=\s*)"[^"]*"(.*)$/m, `$1$2"false"$3`);
  }
  // 3. 有 [vars] → 在其後插入
  if (/^\s*\[vars\]\s*$/m.test(toml)) {
    return toml.replace(
      /^(\s*\[vars\]\s*)$/m,
      `$1\nMULTI_TENANT = "false"  # self-hosted 單租戶（acr update 注入，mcp-account-source §5.5）`,
    );
  }
  // 4. 無 [vars] → 不動（該 worker 不用此 var）
  return toml;
}

/**
 * 移除 self-hosted fork 帳號沒有、會導致 wrangler deploy 失敗的官方專屬 TOML 區塊：
 *   - `[[routes]]`（含 pattern/zone_name）：fork 沒有 arcrun.dev zone
 *   - `[[r2_buckets]]`：dead storage + 綁卡違背開源免費（registry-canon 1.5）
 *   - `[ai]`（Workers AI binding）：免費帳號未必啟用，且自架預設不需要
 * 純文字行級移除（TOML table 以空行 / 下一個 `[` 區塊結束）。worker 仍靠 `workers_dev = true` 對外。
 */
export function stripOfficialOnlyBindings(toml: string): string {
  const lines = toml.split('\n');
  const out: string[] = [];
  let skipping = false;

  const isBlockHeader = (l: string) =>
    /^\s*\[\[?(routes|r2_buckets|ai)\]?\]\s*$/.test(l);

  for (const line of lines) {
    if (isBlockHeader(line)) {
      skipping = true; // 進入要移除的區塊，連同 header 一起丟
      continue;
    }
    if (skipping) {
      // 區塊結束條件：遇到下一個 table header（`[...]`）或空行
      if (/^\s*\[/.test(line)) {
        skipping = false; // 這行是新區塊的開頭，保留並由下方邏輯處理
      } else if (line.trim() === '') {
        skipping = false; // 空行結束區塊；空行本身丟掉避免堆疊空白
        continue;
      } else {
        continue; // 仍在被移除區塊內（pattern/zone_name/binding/bucket_name 等）
      }
    }
    out.push(line);
  }
  return out.join('\n');
}

/** 在 worker 目錄跑 wrangler deploy（用用戶的 CF token + account）。
 *  sharedBin：root 共享 wrangler binary 路徑（見 downloadAndDeploy 2.5）。有則用它且**跳過本地 install**
 *  （deps 從 root node_modules 往上 resolve）；空字串則退回舊行為（各 worker 自裝）。*/
function runWranglerDeploy(dir: string, ctx: DeployContext, sharedBin = ''): void {
  if (!sharedBin && existsSync(join(dir, 'package.json'))) {
    // fallback：共享安裝失敗時才走這條，各 worker 自裝
    const installer = existsSync(join(dir, 'pnpm-lock.yaml'))
      ? ['pnpm', 'install', '--frozen-lockfile']
      : ['npm', 'install', '--no-audit', '--no-fund'];
    runStep(installer[0], installer.slice(1), dir, process.env);
  }
  const wranglerCmd = sharedBin || 'wrangler';
  runStep(wranglerCmd, ['deploy'], dir, {
    ...process.env,
    CLOUDFLARE_API_TOKEN: ctx.apiToken,
    CLOUDFLARE_ACCOUNT_ID: ctx.accountId,
  });
}

/** 跑一個部署步驟，失敗時把 stderr 尾段帶進錯誤訊息——stdio ignore 會吞掉真因，
 *  用戶只看到「Command failed: pnpm install」無從診斷（壓測 2026-06-12：
 *  ERR_PNPM_IGNORED_BUILDS 被吞，10/23 失敗查不到原因）。*/
function runStep(cmd: string, args: string[], dir: string, env: NodeJS.ProcessEnv): void {
  try {
    execFileSync(cmd, args, { cwd: dir, stdio: ['ignore', 'ignore', 'pipe'], env });
  } catch (e) {
    const stderr = (e as { stderr?: Buffer }).stderr?.toString().trim() ?? '';
    const tail = stderr.split('\n').slice(-3).join(' | ').slice(0, 300);
    throw new Error(`${cmd} ${args.join(' ')} 失敗${tail ? `：${tail}` : ''}`);
  }
}
