// @ts-check
/**
 * rule.mjs — 「這個實例該用哪些資源」的**唯一一份**規則。
 *
 * ─────────────────────────────────────────────────────────────────────────────
 * 這份檔案為什麼在這裡（`shared/`），不在 `cli/`
 * ─────────────────────────────────────────────────────────────────────────────
 * leo 2026-08-12：「根本就不應該在 CLI，我要的是一個大家都可以用到的規則。」
 *
 * `.claude/rules/07-thin-shell.md` 的判準口訣：
 *   「這段邏輯換一個介面要不要重寫？」要重寫 → 它是能力，該在共用層。
 *
 * 「該沿用哪幾顆資源」換到安裝器就得重寫一次 ⇒ 它是**能力**，不是薄殼的事。
 * 而它原本住在 `cli/src/lib/resource-resolver.ts` ⇒ 那本身就是違規，
 * 後果也真的發生了：`acr` 那條有這條規則、安裝器那條沒有，於是安裝器照名字找、
 * 找不到就建新的空的 ⇒ Arcrun#97「我按了更新，工作流和登入全不見了」。
 *
 * ── 為什麼不是 cypher-executor 的 API 端點（薄殼原則的標準答案）────────────
 * **自舉**：這條規則要在「決定怎麼裝／怎麼更新」的當下就用得到，而那個當下
 * cypher 可能還不存在（安裝器的工作正是把它生出來），或正要被覆蓋。
 * 而且判斷的輸入是**使用者自己 Cloudflare 帳號上的綁定狀態**——
 * 把它送去一顆平台託管的 worker 換一個答案，等於①讓「能不能安裝」綁在平台是否活著，
 * ②把使用者的帳號拓撲交給第三方。兩件都不該為了形式上的漂亮而做。
 *
 * 薄殼原則要求的是「能力只實作一次」，不是「能力一定要是 HTTP」。
 * 這條規則是**純函式**（唯一的 IO 由呼叫端注入 `ResourceApi`），
 * 所以它用不著變成服務——一份零依賴的 ESM 就能讓每條路吃到同一份判斷。
 *
 * ── 怎麼讓兩條路吃到「同一份」而不是各留一份 ───────────────────────────────
 * 本檔是**唯一被人手維護的實作**，零依賴、不吃任何 node 內建、Workers runtime 可直接跑。
 *   · `acr`：`cli/src/lib/resource-rule.mjs` 是本檔的**逐位元組副本**，
 *     由 `scripts/sync-resource-rule.mjs` 產生（CLI 要能單獨 npm publish，
 *     套件目錄外的檔案打不進 tarball，故必須有這一份）。
 *     `npm run build` / `npm test` 都會跑 `--check`，內容一漂就紅。
 *     ——同 `cli/harness/`（產生物＋世代閘）的既有慣例。
 *   · 安裝器 / 任何 Worker：安裝器本來就會下載本 repo 的 archive（部署來源，
 *     見 `.claude/rules/05-deploy-convention.md`「WASM 來源」），
 *     直接 import 這一份 `shared/resource-rule/rule.mjs` 即可，**不需要再編一次、也不留副本**。
 *     用法見同目錄 README.md。
 *
 * ─────────────────────────────────────────────────────────────────────────────
 * 規則本身（leo 的兩句話）
 * ─────────────────────────────────────────────────────────────────────────────
 * 「如果你沒有裝，就是新的；如果你已經有，原來叫什麼名字就繼續用下去。」
 *
 * 判準是「**這顆 worker 現在綁著誰**」，不是「有沒有叫這個名字的資源」：
 *   1. **已部署的 worker 上綁著什麼，那就是事實** → 原封不動沿用，不管那顆資源叫什麼名字。
 *   2. **只有「確定沒有任何人綁過它」才准新建**（新版本新增的 binding、或真的全新帳號）。
 *   3. **只要有一點說不準就整趟停手**（讀不到綁定／綁著的資源不見了／同一個 binding 指向兩顆／
 *      該更新的 worker 一顆都不在），**什麼都不建、什麼都不部署**，把話說清楚讓人來判斷。
 *
 * ── 為什麼拆成 plan / apply 兩段 ─────────────────────────────────────
 *   `planResources()` **完全不寫入**，只回一份「要沿用什麼、要新建什麼、有什麼不敢動的」。
 *   `applyResourcePlan()` 看到有任何 blocker 就直接拒絕執行。
 *   ⇒「被擋下的時候一顆資源都不會被建出來」是**結構上的保證**，
 *     不是靠某個人記得在對的地方寫 early return。#97 正是死在「先動手、後判斷」。
 *
 * 🔴 這份檔案沒有 import、也不准有。任何依賴都會讓某一條路吃不到它。
 */

/**
 * 這支負責的資源種類。要加新種類（R2/Queue/Hyperdrive…）就加在這裡，
 * 一律走同一道門——不准任何呼叫端自己「照名字 ensure」繞過去。
 * @typedef {'kv_namespace' | 'd1' | 'vectorize'} ResourceKind
 */

/**
 * 從已部署 worker 上讀回來的一條綁定。`value`：KV/D1 是資源 id，Vectorize 是 index 名。
 * @typedef {object} LiveBinding
 * @property {ResourceKind} kind
 * @property {string} binding
 * @property {string} value
 */

/**
 * @typedef {object} ScriptBindings
 * @property {boolean} deployed
 *   false = 這顆 worker 在帳號上還不存在（全新部署），不是「讀取失敗」。讀取失敗要 throw。
 * @property {LiveBinding[]} bindings
 * @property {Record<string, string>} [vars]
 *   這顆 worker 現在掛著的 `plain_text` var（名 → 值）。
 *
 *   🔴 Arcrun#106：#97 只把「資源類」綁定當成事實沿用（KV/D1/Vectorize），
 *     plain_text var 整批沒人管 ⇒ 重部署把它們洗成 repo toml 的預設值。
 *     最痛的一個是 `ARCRUN_BUNDLE_VERSION`（安裝器注入的版本標籤）——
 *     更新完就消失，Portal 設定頁變成「無法讀取目前版本」。
 *     **保留了櫃子，沒保留櫃子上的標籤**。這個欄位就是那些標籤。
 */

/**
 * 規則需要的 CF 能力（收窄成介面，方便離線測試餵假帳號，也讓安裝器用自己的 fetch 實作）。
 * @typedef {object} ResourceApi
 * @property {(script: string) => Promise<ScriptBindings>} getScriptBindings
 * @property {() => Promise<Map<string, string>>} listKvNamespaces  title → id
 * @property {() => Promise<Map<string, string>>} listD1Databases   name → uuid
 * @property {() => Promise<string[]>} listVectorizeIndexes
 * @property {(title: string) => Promise<string>} createKvNamespace
 * @property {(name: string) => Promise<string>} createD1Database
 * @property {(name: string) => Promise<string>} createVectorizeIndex
 */

/**
 * 「這顆 worker 需要這個 binding」。createName 只在**真的要新建**時才會被拿來當名字用。
 * @typedef {object} BindingRequirement
 * @property {ResourceKind} kind
 * @property {string} binding
 * @property {string} worker  需要它的 worker script 名（= wrangler.toml 的 `name`）。
 * @property {string} createName
 * @property {boolean} [createNameIsOurs]
 *   呼叫端在此**聲明**：`createName` 是我們自己用可重現的方式替**這一台實例**算出來的名字
 *   ⇒ 帳號上若已經有一顆**恰好同名**的資源，它只可能是我們上一次沒裝完留下的（Arcrun#123）。
 *
 *   🔴 這個聲明是「接管同名資源」的**唯一**依據，預設 false（fail-closed）。
 *   只有在名字**推導得出、而且推導的輸入是使用者自己的身分**時才准聲明 true——
 *   安裝器的 `arcrun-rag-<slugFromEmail(email)>-kv-<binding>` 就是這種
 *   （slug ＝ `SHA-256('arcrun-rag:' + email)` 取前 8 碼，同一個 email 每次算出同一組名字，
 *   別人算不到、也不會不小心撞上）。
 *
 *   ⚠️ **不准**因為「名字看起來像我們的」就聲明 true。`acr` 那條從 wrangler.toml 讀到的
 *   createName 是裸 binding 名（`WEBHOOKS`）或 toml 宣告的庫名（`arcrun-kbdb`）——
 *   那種名字使用者自己也可能拿去用，**證明不了是我們的**，所以那條路一律不聲明。
 */

/**
 * @typedef {object} PlannedAdopt
 * @property {ResourceKind} kind
 * @property {string} binding
 * @property {string} value
 * @property {string} from  從哪顆已部署的 worker 上讀到的。`reclaimed` 時為空字串——
 *   **沒有任何 worker 綁著它正是接收它的前提**（Arcrun#123），不是漏填。
 * @property {boolean} [reclaimed]
 *   true = 這顆不是從某顆 worker 的綁定讀出來的，而是「帳號上已經有一顆我們自己命名的同名資源、
 *   卻沒有人綁著」⇒ 上一次沒裝完留下的，這次把它接回來用（Arcrun#123）。
 *   給呼叫端做診斷／統計用；**使用者不必知道「殘骸」這個詞**，對外一律講「沿用你原本的資源」。
 */

/**
 * @typedef {object} PlannedCreate
 * @property {ResourceKind} kind
 * @property {string} binding
 * @property {string} createName
 * @property {string[]} wantedBy
 * @property {string[]} alsoBind  其他也指向同一顆資源的 binding（見 shareSameResource）。建一顆，大家共用。
 */

/**
 * @typedef {object} ResourcePlan
 * @property {PlannedAdopt[]} adopt
 * @property {PlannedCreate[]} create
 * @property {string[]} blockers  非空 = 整趟停手。applyResourcePlan 會拒絕執行。
 * @property {Map<string, Record<string, string>>} liveVars
 *   每顆**已部署** worker 現在掛著的 plain_text var（script → 名/值）。未部署的不在裡面。
 *
 *   Arcrun#106：讀綁定的時候本來就把整份 `bindings[]` 拿回來了，var 就在同一份回應裡——
 *   順手帶出來，**不另外打一次 API**，也不新增一種「查不到」的失敗模式
 *   （讀不到綁定這件事已經在上面 blockers 那一關擋掉了）。
 */

/**
 * @typedef {object} ResolvedResource
 * @property {ResourceKind} kind
 * @property {string} binding
 * @property {string} value
 * @property {'adopted' | 'created'} origin
 *   🔴 接回上次沒裝完留下的那顆（`reclaimed`）**仍然算 `adopted`**，不另開第三種值——
 *   它本來就是「沿用既有資源」，而且呼叫端現有的 `origin === 'adopted' / 'created'` 統計
 *   （安裝器那句「沿用你原本的 N 項資源」）不會因為多一種值就悄悄漏數。
 * @property {string} [from]
 * @property {boolean} [reclaimed]  見 PlannedAdopt.reclaimed（Arcrun#123）。
 */

/**
 * @typedef {object} WranglerRequirements
 * @property {string} script  worker script 名（toml 頂層 `name`）。空字串 = 這份 toml 沒宣告 name（不該發生）。
 * @property {Array<{kind: ResourceKind, binding: string, createName: string}>} bindings
 */

/** plan 被擋下時丟這個，讓呼叫端能把每一條原因原文轉給使用者。 */
export class ResourcePlanBlocked extends Error {
  /** @param {string[]} blockers */
  constructor(blockers) {
    super(`資源解析被擋下（${blockers.length} 項）`);
    this.name = 'ResourcePlanBlocked';
    /** @type {string[]} */
    this.blockers = blockers;
  }
}

/**
 * @param {ResourceKind} kind
 * @param {string} binding
 * @returns {string}
 */
export function bindingKey(kind, binding) {
  return `${kind}:${binding}`;
}

/** @type {Record<ResourceKind, string>} */
export const KIND_LABEL = {
  kv_namespace: 'KV namespace',
  d1: 'D1 資料庫',
  vectorize: 'Vectorize index',
};

/**
 * @param {unknown} e
 * @returns {string}
 */
function msg(e) {
  return e instanceof Error ? e.message : String(e);
}

/**
 * 決定每個 binding 要沿用哪顆資源／要不要新建，**不寫入任何東西**。
 *
 * @param {ResourceApi} api
 * @param {readonly BindingRequirement[]} requirements
 * @param {'update' | 'init'} mode
 *   'update' = 這台照定義已經裝過了（見下方「一顆都不在」規則）；'init' = 全新安裝，允許從零建。
 * @returns {Promise<ResourcePlan>}
 */
export async function planResources(api, requirements, mode) {
  /** @type {string[]} */
  const blockers = [];
  /** @type {PlannedAdopt[]} */
  const adopt = [];
  /** @type {PlannedCreate[]} */
  const create = [];

  // ── 1. 先讀「即將被覆蓋的每一顆 worker」現在綁著什麼 ──────────────────
  // 讀取失敗 ≠ 沒有綁。#97 的災情就是把「我查不到」當成「它不存在」。
  const scripts = [...new Set(requirements.map((r) => r.worker))].sort();
  /** @type {Map<string, LiveBinding[]>} */
  const live = new Map();
  /** @type {Map<string, Record<string, string>>} */
  const liveVars = new Map();
  let readFailed = false;
  for (const script of scripts) {
    try {
      const res = await api.getScriptBindings(script);
      if (res.deployed) {
        live.set(script, res.bindings);
        // #106：同一份回應裡的 plain_text var 一起收下（呼叫端要拿它決定哪些 var 該沿用）。
        liveVars.set(script, res.vars ?? {});
      }
    } catch (e) {
      readFailed = true;
      blockers.push(
        `讀不到已部署的 worker「${script}」目前綁著哪些資源（${msg(e)}）。` +
          `不確定它現在用的是哪一顆，就不能重新綁——整趟更新停手，沒有動任何東西。`,
      );
    }
  }

  // 「這台照定義已經裝過了，卻一顆 worker 都找不到」= 我對不上它的實例（名字不同／token 看不到）。
  // 這種時候繼續走下去，等於把一整套資源重新生一遍再綁上去——正是 #97 的形狀，只是換一道門進來。
  if (mode === 'update' && !readFailed && live.size === 0 && scripts.length > 0) {
    blockers.push(
      `在這個 Cloudflare 帳號上找不到任何一顆要更新的 worker（找過：${scripts.join('、')}）。` +
        `acr update 的前提是「這台已經裝好了」——對不上就不猜：` +
        `可能是 API token 看得到的帳號不對，或這台實例的 worker 用了別的名字。` +
        `已停手，沒有新建任何資源。`,
    );
  }

  // ── 2. 逐個 binding 決定：沿用 / 新建 / 停手 ─────────────────────────
  /** @type {Map<string, BindingRequirement[]>} */
  const byKey = new Map();
  for (const req of requirements) {
    const key = bindingKey(req.kind, req.binding);
    const list = byKey.get(key);
    if (list) list.push(req);
    else byKey.set(key, [req]);
  }

  // 帳號上現有的資源，一種只查一次。**名字 → 身分**（KV/D1 是 id，Vectorize 的身分就是名字）。
  //
  // 為什麼連名字都收下來（本來只留 `.values()`）：
  //   · 2b 要問的是「這顆綁著的資源還在不在」→ 只需要 values（身分）。
  //   · 2c 要問的是「這個**名字**是不是已經被佔走了」→ 需要 key。
  // 同一份 API 回應裡兩個問題都答得出來，不必多打一次。
  /** @type {Map<ResourceKind, Map<string, string>>} */
  const existingCache = new Map();
  /** @param {ResourceKind} kind @returns {Promise<Map<string, string>>} */
  const listExistingByName = async (kind) => {
    const hit = existingCache.get(kind);
    if (hit) return hit;
    /** @type {Map<string, string>} */
    let map;
    if (kind === 'kv_namespace') map = await api.listKvNamespaces();
    else if (kind === 'd1') map = await api.listD1Databases();
    else map = new Map((await api.listVectorizeIndexes()).map((n) => [n, n]));
    existingCache.set(kind, map);
    return map;
  };

  for (const [, reqs] of byKey) {
    const { kind, binding } = reqs[0];

    /** @type {Array<{value: string, script: string}>} */
    const found = [];
    for (const [script, bindings] of live) {
      const hit = bindings.find((b) => b.kind === kind && b.binding === binding);
      if (hit) found.push({ value: hit.value, script });
    }
    const distinct = [...new Set(found.map((f) => f.value))];

    // 2a. 同一個 binding 名在不同 worker 上指向不同資源 → 分不出哪個才是使用者要的。
    //     自己挑一個 = 有一半機率把另外那半的資料從畫面上抹掉。不猜。
    if (distinct.length > 1) {
      blockers.push(
        `綁定「${binding}」在不同 worker 上指向不同的 ${KIND_LABEL[kind]}` +
          `（${found.map((f) => `${f.script} → ${f.value}`).join('、')}）。` +
          `分不出哪一顆才是你在用的，不猜——停手。`,
      );
      continue;
    }

    // 2b. 有人綁著它 → 這就是事實，沿用。名字長什麼樣完全不看。
    if (distinct.length === 1) {
      const value = distinct[0];
      /** @type {Map<string, string>} */
      let existing;
      try {
        existing = await listExistingByName(kind);
      } catch (e) {
        blockers.push(
          `查不到帳號上的 ${KIND_LABEL[kind]} 清單，無法確認「${binding}」綁著的 ${value} 還在不在` +
            `（${msg(e)}）。不確定就不動——停手。`,
        );
        continue;
      }
      if (![...existing.values()].includes(value)) {
        // 這正是 #97 的入口：舊版在這裡會安靜地新建一顆空的頂上去。
        blockers.push(
          `worker「${found[0].script}」的「${binding}」綁著 ${KIND_LABEL[kind]} ${value}，` +
            `但這顆在你的 Cloudflare 帳號上找不到了。` +
            `這裡**不會**幫你新建一顆空的頂上去（Arcrun#97 的災情就是那樣來的）——` +
            `請先確認那顆資源是被刪掉了，還是這把 API token 看不到它。`,
        );
        continue;
      }
      adopt.push({ kind, binding, value, from: found[0].script });
      continue;
    }

    // 2c. 沒有任何已部署的 worker 綁過它 → 新版本新增的 binding、全新帳號，
    //     **或者上一次安裝建到一半死掉**（Arcrun#123）。
    //
    // 🔴 原本這裡直接 `create.push()`，理由寫「本來就沒有東西可丟」。**那句話漏了一種狀態**：
    //    資源已經建在帳號上、worker 還沒部署就中斷（逾時／關掉分頁／斷網）。那個當下：
    //      名字已存在 ✅ ／ 有 worker 綁著 ❌ ⇒ 舊邏輯判「可以新建」⇒ CF 回
    //      `a namespace with this account ID and title already exists` ⇒ **這個帳號從此裝不起來**。
    //    封測者 1.4.45 實撞；youlin 拆除時也親眼看到 8 顆「一個 worker 都沒裝出來就被砍」的空殼。
    //
    // ⚠️ **這不是把 Arcrun#97 刪掉的 `ensureKvNamespace` 搬回來**，兩者差在三個地方：
    //    ① #97 是「照名字找 → **找不到就新建一顆頂上去**」；這裡是「照名字找 →
    //       **找到才沿用那一顆，找不到就照舊新建**」。**永遠不會拿新的空資源去頂替既有的**
    //       ——會弄丟資料的是那個動作，不是這個。
    //    ② #97 的比對凌駕於「worker 綁著誰」之上；這裡在 2b 之後，**已部署的綁定仍然絕對優先**，
    //       只有在「確定沒有任何 worker 綁過它」時才輪得到名字說話。
    //    ③ #97 無條件相信名字；這裡要呼叫端**先聲明這個名字推導自使用者自己的身分**
    //       （`createNameIsOurs`），沒聲明就停手。
    /** @type {Map<string, string>} */
    let existingByName;
    try {
      existingByName = await listExistingByName(kind);
    } catch (e) {
      blockers.push(
        `查不到帳號上的 ${KIND_LABEL[kind]} 清單，無法確認「${reqs[0].createName}」這個名字是不是已經被用掉了` +
          `（${msg(e)}）。不確定就不建——停手。`,
      );
      continue;
    }

    const createName = reqs[0].createName;
    const sameName = existingByName.get(createName);
    if (sameName !== undefined) {
      if (!reqs[0].createNameIsOurs) {
        // 名字被佔走，而呼叫端證明不了那顆是我們的 ⇒ 接管它可能蓋掉使用者自己的東西。
        // #97 的反向災情（安靜地接管一顆別人的）跟正向一樣糟 ⇒ fail-closed。
        // 訊息不准叫使用者自己去 Cloudflare 後台動手（#121／D88：機器做得到的事不要丟回給人）。
        blockers.push(
          `你的 Cloudflare 帳號上已經有一個叫「${createName}」的 ${KIND_LABEL[kind]}，` +
            `但沒有任何 worker 綁著它，我也無法證明那顆是這次安裝建的。` +
            `直接拿來用有可能蓋掉你自己的東西，所以停手了——沒有建立或改動任何資源。` +
            `請把這則訊息回報給我們（錯誤碼 RES-NAME-TAKEN/${kind}/${binding}），這需要我們處理。`,
        );
        continue;
      }
      // 名字是我們替這台實例算出來的（見 createNameIsOurs 的推導條件）⇒ 這顆只可能是
      // 我們上一次沒裝完留下的。沿用它＝把上次做到一半的進度接回來，**不會有任何損失**：
      //   · 它若是空的（最常見）→ 等同於新建一顆，只是省下 CF 那個「名字已存在」的拒絕。
      //   · 它若有資料（更早裝過、後來 worker 被拆掉）→ 沿用正是把使用者的東西接回來。
      adopt.push({ kind, binding, value: sameName, from: '', reclaimed: true });
      continue;
    }

    create.push({
      kind,
      binding,
      createName,
      wantedBy: [...new Set(reqs.map((r) => r.worker))],
      alsoBind: [],
    });
  }

  return { adopt, create: shareSameResource(adopt, create, byKey), blockers, liveVars };
}

/**
 * 收斂「不同 binding 其實是同一顆資源」的情況。
 *
 * 判準是 **toml 自己宣告的名字**（`database_name` / `index_name`），不是使用者那側的資源名——
 * cypher 的 `CREDENTIALS_DB` 與 kbdb 的 `DB` 都寫 `database_name = "arcrun-kbdb"`，
 * 那是**我們**在宣告「這兩個綁定指向同一顆庫」，跟 #97 那種「拿名字去猜使用者的資源」是兩回事。
 *
 * 沒有這一步會出兩種錯：
 *   ① 全新安裝時建出兩顆同名 D1，KBDB 的資料與 credential 目錄從此分家。
 *   ② 一邊已部署（沿用既有）、另一邊沒有（新建一顆空的）→ 半套資料，比全壞更難查。
 *
 * @param {PlannedAdopt[]} adopt
 * @param {PlannedCreate[]} create
 * @param {Map<string, BindingRequirement[]>} byKey
 * @returns {PlannedCreate[]}
 */
function shareSameResource(adopt, create, byKey) {
  /** @param {ResourceKind} kind @param {string} binding @returns {string | undefined} */
  const declaredName = (kind, binding) =>
    byKey.get(bindingKey(kind, binding))?.[0]?.createName;

  /** @type {PlannedCreate[]} */
  const out = [];
  /** @type {Map<string, PlannedCreate>} */
  const groups = new Map();

  for (const c of create) {
    const groupKey = `${c.kind} ${c.createName}`;

    // ① 已經有 binding 沿用到同一顆（依 toml 宣告）→ 跟著沿用，不要另外建一顆。
    const twin = adopt.find(
      (a) => a.kind === c.kind && declaredName(a.kind, a.binding) === c.createName,
    );
    if (twin) {
      adopt.push({ kind: c.kind, binding: c.binding, value: twin.value, from: twin.from });
      continue;
    }

    // ② 同一趟裡有多個 binding 要建同一顆 → 建一次，其他人共用。
    const head = groups.get(groupKey);
    if (head) {
      head.alsoBind.push(c.binding);
      head.wantedBy = [...new Set([...head.wantedBy, ...c.wantedBy])];
      continue;
    }
    groups.set(groupKey, c);
    out.push(c);
  }
  return out;
}

/**
 * 照 plan 動手：沿用的原樣帶出來，該建的才建。
 * 有任何 blocker 直接丟 ResourcePlanBlocked，**一顆都不建**。
 *
 * @param {ResourceApi} api
 * @param {ResourcePlan} plan
 * @returns {Promise<Map<string, ResolvedResource>>}
 */
export async function applyResourcePlan(api, plan) {
  if (plan.blockers.length > 0) throw new ResourcePlanBlocked(plan.blockers);

  /** @type {Map<string, ResolvedResource>} */
  const out = new Map();
  for (const a of plan.adopt) {
    out.set(bindingKey(a.kind, a.binding), {
      kind: a.kind,
      binding: a.binding,
      value: a.value,
      origin: 'adopted',
      from: a.from,
      ...(a.reclaimed ? { reclaimed: true } : {}),
    });
  }
  /** @type {string[]} */
  const madeSoFar = [];
  for (const c of plan.create) {
    /** @type {string} */
    let value;
    try {
      if (c.kind === 'kv_namespace') value = await api.createKvNamespace(c.createName);
      else if (c.kind === 'd1') value = await api.createD1Database(c.createName);
      else value = await api.createVectorizeIndex(c.createName);
    } catch (e) {
      // 半途失敗：已經建出來的那幾顆還沒被綁到任何 worker 上。**要講出來**——
      // 不講的話它們就是帳號上一批沒人認得的孤兒，而且下次重跑會再建一批。
      const orphans = madeSoFar.length > 0
        ? `\n  已經建好但還沒綁上任何 worker 的：${madeSoFar.join('、')}（重跑前可先刪掉，或留著讓下次沿用）`
        : '';
      throw new Error(`建 ${KIND_LABEL[c.kind]}「${c.createName}」失敗：${msg(e)}${orphans}`);
    }
    madeSoFar.push(`${KIND_LABEL[c.kind]} ${c.createName}`);
    for (const binding of [c.binding, ...c.alsoBind]) {
      out.set(bindingKey(c.kind, binding), { kind: c.kind, binding, value, origin: 'created' });
    }
  }
  return out;
}

// ─────────────────────────────────────────────────────────────────────────────
// wrangler.toml → 需求清單
// ─────────────────────────────────────────────────────────────────────────────

/**
 * wrangler.toml 的 table 名 → 資源種類。需求解析與注入共用同一張表，兩邊才不會對不上。
 * @type {Record<string, ResourceKind>}
 */
export const TABLE_KIND = {
  kv_namespaces: 'kv_namespace',
  d1_databases: 'd1',
  vectorize: 'vectorize',
};

/**
 * 從 wrangler.toml 抽出「這顆 worker 需要哪些資源綁定」。
 *
 * 刻意寫成行掃描而不引 TOML parser：注入端（injectWranglerConfig）本來就是純文字操作，
 * 兩邊用同一種視角看這份檔案才不會對不上。註解掉的區塊**不算需求**
 * （kbdb 的 `[[vectorize]]` 預設是註解狀態，要開語義查詢時才會被取消註解 → 那時才成為需求）。
 *
 * 也是「零依賴」的一部分：不引 TOML parser ⇒ 安裝器 import 這支不必多裝任何東西。
 *
 * @param {string} toml
 * @returns {WranglerRequirements}
 */
export function parseWranglerRequirements(toml) {
  let script = '';
  let seenTable = false;
  /** @type {WranglerRequirements['bindings']} */
  const bindings = [];

  /** @type {ResourceKind | null} */
  let kind = null;
  let binding = '';
  let createName = '';

  const flush = () => {
    if (kind && binding) {
      bindings.push({ kind, binding, createName: createName || binding });
    }
    kind = null;
    binding = '';
    createName = '';
  };

  for (const raw of toml.split('\n')) {
    const line = raw.trim();
    if (line === '' || line.startsWith('#')) continue;

    const table = line.match(/^\[\[?([A-Za-z0-9_]+)\]?\]$/);
    if (table) {
      flush();
      seenTable = true;
      kind = TABLE_KIND[table[1]] ?? null;
      continue;
    }

    const kv = line.match(/^([A-Za-z0-9_]+)\s*=\s*"([^"]*)"/);
    if (!kv) continue;
    const [, key, value] = kv;

    if (!seenTable && key === 'name') {
      script = value;
      continue;
    }
    if (!kind) continue;
    if (key === 'binding') binding = value;
    // 只有 D1／Vectorize 在 toml 裡帶得出「名字」；KV 沒有，退回用 binding 名（見 flush）。
    else if (key === 'database_name' || key === 'index_name') createName = value;
  }
  flush();

  return { script, bindings };
}

// ─────────────────────────────────────────────────────────────────────────────
// Cloudflare `/settings` 回應 → 事實（兩條路都要用同一種眼睛看）
// ─────────────────────────────────────────────────────────────────────────────

/**
 * CF `GET /accounts/{id}/workers/scripts/{script}/settings` 回的 binding 原始形狀
 * （同一種資源在不同 API 版本欄位名不一，故全都收）。
 *
 * @typedef {object} RawWorkerBinding
 * @property {string} [type]
 * @property {string} [name]
 * @property {string} [namespace_id]
 * @property {string} [id]
 * @property {string} [database_id]
 * @property {string} [index_name]
 * @property {string} [text]  `plain_text` 綁定的值（#106；secret_text 不會回值，本來就讀不到，也不該讀）。
 */

/**
 * 把 CF 的 binding 陣列收斂成規則認得的三種資源。不認得的型別直接略過。
 *
 * 🔴 這支**刻意放在規則裡**，不留在各自的 CF client：
 *   「什麼才算『這顆 worker 綁著某顆資源』」是規則的一部分。
 *   兩條路各自解讀 CF 回應 = 漂移會從這裡長回來（例如一邊認 `namespace_id`、
 *   另一邊只認 `id`，於是一邊看得到綁定、另一邊看不到 → 後者又去新建了）。
 *
 * @param {RawWorkerBinding[]} raw
 * @returns {LiveBinding[]}
 */
export function normalizeLiveBindings(raw) {
  /** @type {LiveBinding[]} */
  const out = [];
  for (const b of raw) {
    if (!b?.name) continue;
    if (b.type === 'kv_namespace') {
      const value = b.namespace_id ?? b.id;
      if (value) out.push({ kind: 'kv_namespace', binding: b.name, value });
    } else if (b.type === 'd1' || b.type === 'd1_database') {
      const value = b.id ?? b.database_id;
      if (value) out.push({ kind: 'd1', binding: b.name, value });
    } else if (b.type === 'vectorize') {
      if (b.index_name) out.push({ kind: 'vectorize', binding: b.name, value: b.index_name });
    }
  }
  return out;
}

/**
 * 抽出已部署 worker 上的 `plain_text` var（#106）。
 *
 * 只收 `plain_text`——**`secret_text` 一律不碰**（CF 本來就不回值，也不該被搬來搬去；
 * wrangler deploy 不會動 secret，它們自己會留著）。
 *
 * @param {RawWorkerBinding[]} raw
 * @returns {Record<string, string>}
 */
export function normalizeLiveVars(raw) {
  /** @type {Record<string, string>} */
  const out = {};
  for (const b of raw) {
    if (b?.type === 'plain_text' && b.name && typeof b.text === 'string') out[b.name] = b.text;
  }
  return out;
}
