/**
 * 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';

/** 部署狀態 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;
}

/**
 * 組 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;
}

/**
 * init 要建立的 KV namespace（title）。
 * 前 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;

/** 部署後要提示用戶手動 `wrangler secret put ENCRYPTION_KEY` 的 Worker。*/
export const SECRET_TARGET_WORKERS = [
  'arcrun-cypher-executor',
  'arcrun-auth-static-key',
  'arcrun-auth-service-account',
] 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;
  kvNamespaceIds: Record<string, string>; // title → id
  d1DatabaseId?: string; // KBDB Base D1 (arcrun-kbdb); injected into kbdb wrangler.toml
  // 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;
}

/** Vectorize index 名（kbdb embed 模組用）。bge-base-en-v1.5 = 768 維、cosine。 */
export const KBDB_VECTORIZE_INDEX = 'arcrun-kbdb-embed';

export interface DeployResult {
  implemented: boolean;
  cypherExecutorUrl?: string;
  mcpUrl?: string;            // self-hosted 自己的 MCP worker URL（mcp-account-source §3）
  message: string;
}

/** 偵測 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 } = {},
): Promise<DeployResult> {
  // 1. 下載 + 解壓 Gitea archive tarball
  let root: string;
  try {
    root = await downloadRepoTarball(ref);
  } 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[] = [];

  // 2.6 語義查詢（issue #7 / T2.4）：開 kbdb_embed → 先確保 Vectorize index 存在（REST，冪等），
  //      再由 injectWranglerConfig 取消 kbdb toml 的 [[vectorize]]+[ai] 註解 → embed 模組上線。
  //      失敗不致命（收進 failures，base 仍可部署、維持 keyword）。
  if (ctx.kbdbEmbed) {
    try {
      process.stdout.write(chalk.gray('  → 開語義查詢：確保 Vectorize index 存在...'));
      await ensureVectorizeIndex(ctx);
      // Arcrun#11 根因修復：光建 index 不夠——Vectorize 要 filter 某 metadata 欄位，該欄必須先建
      //   metadata index，否則帶 owner_id/entry_type/source 過濾的語意查詢一律回 0。冪等，隨 index 一起確保。
      await ensureVectorizeMetadataIndexes(ctx);
      console.log(chalk.green(' ✓'));
    } catch (e) {
      console.log(chalk.yellow(' ⚠'));
      failures.push(`Vectorize index (${KBDB_VECTORIZE_INDEX}): ${e instanceof Error ? e.message : String(e)}`);
    }
  }

  // 3. 對每個 worker：注入 KV id（+ cypher WORKER_SUBDOMAIN）→ wrangler deploy。tier1 先 tier2 後。
  // 逐 worker 串流進度（每個含 pnpm install + wrangler deploy，沉默會讓人以為卡住——
  //   壓測 2026-06-11 richblack 觀察：「D1 ✓」後停很久其實在這個迴圈靜默部署 20+ worker）。
  const allDirs = [...tier1, ...tier2];
  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);
      // 注入後算指紋：與 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-primitives-wasm T2（credential-store-migration.md §2.2）：
    //     credentials 目錄表（api_key/name/service/sensitivity/secret_ref/created_at/last_used_at）。
    //     同一顆 D1（與 KBDB base 共用），冪等 IF NOT EXISTS，套用機制與 0001_base.sql 完全相同
    //     （同一個 applyD1Migration helper，同一支 CF D1 query API）。D19：這張表不含密文，
    //     密文本體住在 Workers per-script Secrets（見 cypher-executor/src/routes/credentials.ts）。
    const credMigPath = join(root, 'kbdb', 'migrations', '0002_credentials.sql');
    if (existsSync(credMigPath)) {
      try {
        await applyD1Migration(ctx, readFileSync(credMigPath, 'utf8'));
      } catch (e) {
        failures.push(`D1 migration 0002_credentials (${ctx.d1DatabaseId}): ${e instanceof Error ? e.message : String(e)}`);
      }
    } else {
      failures.push(`D1 migration: 部署物缺 kbdb/migrations/0002_credentials.sql（${credMigPath}）`);
    }
  }

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

/**
 * 確保 KBDB embed 用的 Vectorize index 存在（issue #7 / T2.4）。
 * REST `POST /accounts/{id}/vectorize/v2/indexes`（dimensions=768/metric=cosine，對齊 bge-base-en-v1.5）。
 * 冪等：已存在（CF 回「already exists」類錯）視為成功，不報錯。用 init 已驗的 apiToken+accountId。
 */
async function ensureVectorizeIndex(ctx: DeployContext): Promise<void> {
  const url = `https://api.cloudflare.com/client/v4/accounts/${ctx.accountId}/vectorize/v2/indexes`;
  const res = await fetch(url, {
    method: 'POST',
    headers: { Authorization: `Bearer ${ctx.apiToken}`, 'Content-Type': 'application/json' },
    body: JSON.stringify({
      name: KBDB_VECTORIZE_INDEX,
      config: { dimensions: 768, metric: 'cosine' },
      description: 'arcrun KBDB optional embed module (issue #7)',
    }),
    signal: AbortSignal.timeout(60_000),
  });
  if (res.ok) return;
  // 冪等：已存在 → 視為成功（CF 回 409 或 errors 含 already exists / duplicate）。
  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)) return;
  throw new Error(msg);
}

/** 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）。
 */
async function ensureVectorizeMetadataIndexes(ctx: DeployContext): Promise<void> {
  const url = `https://api.cloudflare.com/client/v4/accounts/${ctx.accountId}/vectorize/v2/indexes/${KBDB_VECTORIZE_INDEX}/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): Promise<string> {
  // 唯一 cache-buster query param：對不同 query 視為不同請求 → 繞過 stale 快取。
  const bust = `${Date.now()}-${Math.random().toString(36).slice(2)}`;
  const url = buildArchiveUrl(ref, bust);
  console.log(chalk.gray(`  → 從 Gitea 下載最新版本（${ARCRUN_REPO}@${ref}，約 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): void {
  if (!existsSync(tomlPath)) return;
  let toml = readFileSync(tomlPath, 'utf8');

  // 對每個已建立的 KV namespace：把對應 binding 的 id 換成用戶的。
  // 匹配 `[[kv_namespaces]] ... binding = "NAME" ... id = "OLD"` 的 id 行。
  for (const [binding, id] of Object.entries(ctx.kvNamespaceIds)) {
    if (!id) continue;
    const re = new RegExp(
      `(binding\\s*=\\s*"${binding}"\\s*\\n\\s*id\\s*=\\s*")[^"]*(")`,
      'g',
    );
    toml = toml.replace(re, `$1${id}$2`);
  }

  // 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`,
    );
  }

  // KBDB Base: inject user's D1 database_id into [[d1_databases]] (placeholder in repo toml)
  if (ctx.d1DatabaseId && /database_id\s*=/.test(toml)) {
    toml = toml.replace(
      /(database_id\s*=\s*")[^"]*(")/,
      `$1${ctx.d1DatabaseId}$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');
  }

  writeFileSync(tomlPath, toml, 'utf8');
}

/**
 * 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}` : ''}`);
  }
}
