/**
 * Arcrun#97 迴歸守衛 —— 「跑完更新，使用者的東西還在原地」
 *
 * 2026-08-12 實害：leo 跑了一次例行更新，跑完工作流一支都沒有、portal 把他登出、
 * 總圖是空的、子庫全不見。資料沒被刪，但 worker 被重新綁到 9 顆新建的空 KV + 1 顆空 D1 上。
 *
 * 根因：更新「照名字」確保資源存在——拿 binding 名（WEBHOOKS）當 CF 上的資源標題去找，
 * 安裝器建的資源叫 `arcrun-rag-<instance>-kv-webhooks`，永遠對不上 ⇒ 每次更新都新建一顆綁上去。
 *
 * 這份測試用一台**照安裝器命名慣例**的假實例（不是 leo 的實例，不碰 leo21c）跑真正的
 * 解析程式碼（planResources / applyResourcePlan / renderWranglerToml），對照更新前後：
 *   ① 工作流數、登入狀態、子庫數  ② 帳號上的資源顆數  ③ 找不到既有資源時要停手
 */

import { test } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import { fileURLToPath } from 'node:url';

import {
  planResources,
  applyResourcePlan,
  parseWranglerRequirements,
  bindingKey,
  ResourcePlanBlocked,
  type BindingRequirement,
  type ResourceApi,
  type ScriptBindings,
  type LiveBinding,
  type ResolvedResource,
} from '../src/lib/resource-resolver.ts';
import {
  renderWranglerToml,
  REQUIRED_KV_NAMESPACES,
  type DeployContext,
} from '../src/lib/deploy.ts';
import { CfAccountClient } from '../src/lib/cf-api.ts';

const REPO = join(fileURLToPath(new URL('.', import.meta.url)), '..', '..');

/** 這台假實例上跑著的四顆 worker（有資源綁定的那幾顆）。 */
const WORKER_TOMLS = [
  'cypher-executor/wrangler.toml',
  'registry/wrangler.toml',
  'mcp/wrangler.toml',
  'kbdb/wrangler.toml',
];

const CTX: DeployContext = {
  accountId: 'acct-test',
  apiToken: 'tok-test',
  workerSubdomain: 'yuga3bse',
  selfHosted: true,
  kbdbEmbed: true,
};

// ─────────────────────────────────────────────────────────────────────────────
// 假的 Cloudflare 帳號：完全照「安裝器裝出來」的樣子命名
// ─────────────────────────────────────────────────────────────────────────────

const INSTANCE = 'yuga3bse';

interface FakeOpts {
  /** 讓某顆 worker 的綁定讀取失敗（模擬 API 掛掉 / 權限不足）。 */
  failBindingsFor?: string;
  /** 從帳號上「弄不見」某顆 KV，但 worker 上還綁著它（模擬資源被刪）。 */
  deleteKvTitle?: string;
  /** 完全沒有任何已部署的 worker（模擬名字對不上 / token 看錯帳號）。 */
  nothingDeployed?: boolean;
}

class FakeCloudflare implements ResourceApi {
  /** title → id */
  kv = new Map<string, string>();
  /** name → uuid */
  d1 = new Map<string, string>();
  vectorize: string[] = [];
  /** script → bindings */
  scripts = new Map<string, LiveBinding[]>();

  /** 使用者的東西：kvId → (key → value) */
  kvData = new Map<string, Map<string, string>>();
  /** d1Id → 子庫名單 */
  d1Libraries = new Map<string, string[]>();

  /** 這趟總共建立了什麼（驗「顆數不增加」用）。 */
  createdKv: string[] = [];
  createdD1: string[] = [];
  createdVectorize: string[] = [];

  constructor(private opts: FakeOpts = {}) {
    // 安裝器的命名慣例：arcrun-rag-<instance>-kv-<binding 小寫>
    for (const binding of REQUIRED_KV_NAMESPACES) {
      const title = `arcrun-rag-${INSTANCE}-kv-${binding.toLowerCase()}`;
      const id = `kvid-${binding.toLowerCase()}`;
      this.kv.set(title, id);
      this.kvData.set(id, new Map());
    }
    this.d1.set(`arcrun-rag-${INSTANCE}-kbdb`, 'd1id-kbdb');
    this.vectorize.push(`arcrun-rag-${INSTANCE}-embed`);

    // 使用者的東西
    this.kvData.get('kvid-webhooks')!.set('webhook:leo:daily-digest', '{}');
    this.kvData.get('kvid-webhooks')!.set('webhook:leo:inbox-sync', '{}');
    this.kvData.get('kvid-webhooks')!.set('webhook:leo:rag-ingest', '{}');
    this.kvData.get('kvid-sessions_kv')!.set('session:leo-abc123', '{"user":"leo"}');
    this.d1Libraries.set('d1id-kbdb', ['general', '課程', '客戶', '研究']);

    if (!opts.nothingDeployed) {
      const kvB = (b: string): LiveBinding =>
        ({ kind: 'kv_namespace', binding: b, value: `kvid-${b.toLowerCase()}` });
      this.scripts.set('arcrun-cypher-executor', [
        kvB('EXEC_CONTEXT'), kvB('WEBHOOKS'), kvB('CREDENTIALS_KV'), kvB('ANALYTICS_KV'),
        kvB('RECIPES'), kvB('USERS_KV'), kvB('SESSIONS_KV'),
        { kind: 'd1', binding: 'CREDENTIALS_DB', value: 'd1id-kbdb' },
      ]);
      this.scripts.set('arcrun-registry', [kvB('SUBMISSIONS_KV'), kvB('ANALYTICS_KV')]);
      this.scripts.set('arcrun-mcp', [kvB('OAUTH_KV')]);
      this.scripts.set('arcrun-kbdb', [
        { kind: 'd1', binding: 'DB', value: 'd1id-kbdb' },
        { kind: 'vectorize', binding: 'VECTORIZE', value: `arcrun-rag-${INSTANCE}-embed` },
      ]);
    }

    if (opts.deleteKvTitle) this.kv.delete(opts.deleteKvTitle);
  }

  async getScriptBindings(script: string): Promise<ScriptBindings> {
    if (this.opts.failBindingsFor === script) throw new Error('HTTP 500 (CF API 暫時掛掉)');
    const b = this.scripts.get(script);
    return b ? { deployed: true, bindings: b } : { deployed: false, bindings: [] };
  }
  async listKvNamespaces(): Promise<Map<string, string>> { return new Map(this.kv); }
  async listD1Databases(): Promise<Map<string, string>> { return new Map(this.d1); }
  async listVectorizeIndexes(): Promise<string[]> { return [...this.vectorize]; }
  async createKvNamespace(title: string): Promise<string> {
    // 🔴 Arcrun#123：真的 Cloudflare **不准同名**——
    //    `a namespace with this account ID and title already exists`。
    //    這個假帳號原本沒有模擬這條限制，於是「上一次裝到一半死掉」的帳號在測試裡
    //    看起來只是「多建幾顆孤兒」，實際上是**再也裝不起來**。少了這一行，#123 測不出來。
    if (this.kv.has(title)) {
      throw new Error('a namespace with this account ID and title already exists');
    }
    const id = `NEW-kvid-${this.createdKv.length}`;
    this.kv.set(title, id);
    this.kvData.set(id, new Map());          // 新建的是**空的**——災情就是綁到這種東西上
    this.createdKv.push(title);
    return id;
  }
  async createD1Database(name: string): Promise<string> {
    const id = `NEW-d1id-${this.createdD1.length}`;
    this.d1.set(name, id);
    this.d1Libraries.set(id, []);
    this.createdD1.push(name);
    return id;
  }
  async createVectorizeIndex(name: string): Promise<string> {
    this.vectorize.push(name);
    this.createdVectorize.push(name);
    return name;
  }
}

// ─────────────────────────────────────────────────────────────────────────────
// 共用：從真的 wrangler.toml 解析需求（走與 downloadAndDeploy 相同的路徑）
// ─────────────────────────────────────────────────────────────────────────────

function collectRequirements(): { requirements: BindingRequirement[]; tomls: Map<string, string> } {
  const requirements: BindingRequirement[] = [];
  const tomls = new Map<string, string>();
  for (const rel of WORKER_TOMLS) {
    const raw = readFileSync(join(REPO, rel), 'utf8');
    tomls.set(rel, raw);
    const parsed = parseWranglerRequirements(renderWranglerToml(raw, CTX, new Map()));
    for (const b of parsed.bindings) requirements.push({ ...b, worker: parsed.script });
  }
  return { requirements, tomls };
}

/** 模擬「部署」：把解析結果注入 toml，再從注入後的 toml 讀回 worker 實際會綁到的資源。 */
function deployAndReadBindings(
  tomls: Map<string, string>,
  resolved: Map<string, ResolvedResource>,
): Map<string, Map<string, string>> {
  const out = new Map<string, Map<string, string>>();
  for (const [rel, raw] of tomls) {
    const rendered = renderWranglerToml(raw, CTX, resolved);
    const script = parseWranglerRequirements(rendered).script;
    const bound = new Map<string, string>();
    let kind: string | null = null;
    let binding = '';
    for (const line of rendered.split('\n')) {
      const t = line.trim();
      if (t.startsWith('#')) continue;
      const table = t.match(/^\[\[?([A-Za-z0-9_]+)\]?\]$/);
      if (table) { kind = table[1]; binding = ''; continue; }
      const m = t.match(/^([A-Za-z0-9_]+)\s*=\s*"([^"]*)"/);
      if (!m) continue;
      if (m[1] === 'binding') binding = m[2];
      else if (binding && (
        (kind === 'kv_namespaces' && m[1] === 'id')
        || (kind === 'd1_databases' && m[1] === 'database_id')
        || (kind === 'vectorize' && m[1] === 'index_name')
      )) bound.set(binding, m[2]);
    }
    out.set(script, bound);
  }
  return out;
}

// ═════════════════════════════════════════════════════════════════════════════
// ① 更新前後：工作流數、登入狀態、子庫數 —— 三個都不能少
// ═════════════════════════════════════════════════════════════════════════════

test('#97 ①：安裝器裝出來的實例跑更新——工作流／登入／子庫更新前後完全一致', async () => {
  const cf = new FakeCloudflare();
  const { requirements, tomls } = collectRequirements();

  const before = {
    workflows: cf.kvData.get('kvid-webhooks')!.size,
    sessions: cf.kvData.get('kvid-sessions_kv')!.size,
    libraries: cf.d1Libraries.get('d1id-kbdb')!.length,
  };
  assert.deepEqual(before, { workflows: 3, sessions: 1, libraries: 4 }, '前置資料要先擺好');

  const plan = await planResources(cf, requirements, 'update');
  assert.deepEqual(plan.blockers, [], '一台健康的實例不該有任何 blocker');
  const resolved = await applyResourcePlan(cf, plan);

  const bound = deployAndReadBindings(tomls, resolved);

  // 更新後，worker 綁到的還是使用者原本那幾顆（名字完全沒對上，但那不重要）
  const cypher = bound.get('arcrun-cypher-executor')!;
  assert.equal(cypher.get('WEBHOOKS'), 'kvid-webhooks');
  assert.equal(cypher.get('SESSIONS_KV'), 'kvid-sessions_kv');
  assert.equal(cypher.get('CREDENTIALS_DB'), 'd1id-kbdb');
  assert.equal(bound.get('arcrun-kbdb')!.get('DB'), 'd1id-kbdb');
  assert.equal(bound.get('arcrun-mcp')!.get('OAUTH_KV'), 'kvid-oauth_kv');
  assert.equal(bound.get('arcrun-registry')!.get('SUBMISSIONS_KV'), 'kvid-submissions_kv');
  assert.equal(bound.get('arcrun-kbdb')!.get('VECTORIZE'), `arcrun-rag-${INSTANCE}-embed`);

  const after = {
    workflows: cf.kvData.get(cypher.get('WEBHOOKS')!)!.size,
    sessions: cf.kvData.get(cypher.get('SESSIONS_KV')!)!.size,
    libraries: cf.d1Libraries.get(bound.get('arcrun-kbdb')!.get('DB')!)!.length,
  };
  assert.deepEqual(after, before, '更新後使用者看到的東西必須跟更新前一模一樣');
});

// ═════════════════════════════════════════════════════════════════════════════
// ② 帳號上的資源顆數不增加（災情當天：9 顆 KV → 18 顆、1 顆 D1 → 2 顆）
// ═════════════════════════════════════════════════════════════════════════════

test('#97 ②：更新不會在帳號上多生任何資源', async () => {
  const cf = new FakeCloudflare();
  const kvBefore = cf.kv.size;
  const d1Before = cf.d1.size;
  const vecBefore = cf.vectorize.length;
  assert.deepEqual([kvBefore, d1Before, vecBefore], [9, 1, 1]);

  const { requirements } = collectRequirements();
  const plan = await planResources(cf, requirements, 'update');
  await applyResourcePlan(cf, plan);

  assert.deepEqual(cf.createdKv, [], '不該新建任何 KV');
  assert.deepEqual(cf.createdD1, [], '不該新建任何 D1');
  assert.deepEqual(cf.createdVectorize, [], '不該新建任何 Vectorize index');
  assert.deepEqual([cf.kv.size, cf.d1.size, cf.vectorize.length], [9, 1, 1]);
});

test('#97 ②對照組：舊的「照名字 ensure」在同一台實例上會生 9 顆 KV + 1 顆 D1', async () => {
  // 這段是**修好之前**的演算法（commit e69d6bb 時的 cli/src/commands/update.ts:52-68 與
  // cf-api.ts 的 ensureKvNamespace/ensureD1Database），照原樣重寫在這裡當對照組。
  // 目的：把「災情是怎麼發生的」釘成可執行的事實，而不是只留在 issue 的文字裡。
  const cf = new FakeCloudflare();
  const existing = await cf.listKvNamespaces();
  for (const title of REQUIRED_KV_NAMESPACES) {
    if (!existing.get(title)) await cf.createKvNamespace(title);   // ← 名字對不上 ⇒ 每個都新建
  }
  const d1s = await cf.listD1Databases();
  if (!d1s.get('arcrun-kbdb')) await cf.createD1Database('arcrun-kbdb');

  assert.equal(cf.createdKv.length, 9, '舊做法：9 顆 KV 全部重建（對上災情當天的數字）');
  assert.equal(cf.createdD1.length, 1, '舊做法：D1 也重建一顆');
  assert.equal(cf.kv.size, 18, '9 → 18');
  assert.equal(cf.d1.size, 2, '1 → 2');
  // 而且新建的那幾顆是空的 —— 使用者的工作流就是這樣「不見」的
  assert.equal(cf.kvData.get(cf.kv.get('WEBHOOKS')!)!.size, 0);
});

// ═════════════════════════════════════════════════════════════════════════════
// ③ 反向驗證：找不到既有資源 → 停下來說清楚，不是安靜新建一顆綁上去
// ═════════════════════════════════════════════════════════════════════════════

test('#97 ③-a：worker 綁著的 KV 在帳號上不見了 → 停手，一顆都不建', async () => {
  const cf = new FakeCloudflare({ deleteKvTitle: `arcrun-rag-${INSTANCE}-kv-webhooks` });
  const { requirements } = collectRequirements();

  const plan = await planResources(cf, requirements, 'update');
  assert.ok(plan.blockers.length > 0, '要有 blocker');
  const said = plan.blockers.join('\n');
  assert.match(said, /WEBHOOKS/, '要指名是哪個綁定');
  assert.match(said, /kvid-webhooks/, '要指名是哪一顆資源');
  assert.match(said, /找不到/, '要說清楚發生什麼事');
  assert.ok(!plan.create.some((c) => c.binding === 'WEBHOOKS'), '絕不能把它排進「要新建」');

  await assert.rejects(() => applyResourcePlan(cf, plan), ResourcePlanBlocked);
  assert.deepEqual(cf.createdKv, [], '被擋下時一顆資源都不能被建出來');
  assert.deepEqual(cf.createdD1, []);
});

test('#97 ③-b：讀不到某顆 worker 現在綁什麼 → 當「我不知道」而不是「它沒有」', async () => {
  const cf = new FakeCloudflare({ failBindingsFor: 'arcrun-cypher-executor' });
  const { requirements } = collectRequirements();

  const plan = await planResources(cf, requirements, 'update');
  assert.match(plan.blockers.join('\n'), /arcrun-cypher-executor/);
  await assert.rejects(() => applyResourcePlan(cf, plan), ResourcePlanBlocked);
  assert.deepEqual(cf.createdKv, []);
});

test('#97 ③-c：update 卻一顆 worker 都找不到 → 停手，不當成全新安裝重建一整套', async () => {
  const cf = new FakeCloudflare({ nothingDeployed: true });
  const { requirements } = collectRequirements();

  const plan = await planResources(cf, requirements, 'update');
  assert.match(plan.blockers.join('\n'), /找不到任何一顆要更新的 worker/);
  await assert.rejects(() => applyResourcePlan(cf, plan), ResourcePlanBlocked);
  assert.deepEqual(cf.createdKv, []);
});

test('#97 ③-d：同一個 binding 在不同 worker 上指向不同資源 → 不猜，停手', async () => {
  const cf = new FakeCloudflare();
  // registry 的 ANALYTICS_KV 被指到別顆（真實情境：有人手動改過其中一邊）
  cf.scripts.get('arcrun-registry')!.find((b) => b.binding === 'ANALYTICS_KV')!.value = 'kvid-other';
  cf.kv.set('some-other-kv', 'kvid-other');
  const { requirements } = collectRequirements();

  const plan = await planResources(cf, requirements, 'update');
  assert.match(plan.blockers.join('\n'), /ANALYTICS_KV/);
  assert.deepEqual(cf.createdKv, []);
});

// ═════════════════════════════════════════════════════════════════════════════
// 合法的新建：只有「確定沒人綁過」時才准
// ═════════════════════════════════════════════════════════════════════════════

test('#97：新版本新增的 binding（沒有任何已部署 worker 綁過）才准新建', async () => {
  const cf = new FakeCloudflare();
  cf.scripts.set('arcrun-mcp', []);          // mcp 已部署，但還沒有 OAUTH_KV（舊版本裝的）
  cf.kv.delete(`arcrun-rag-${INSTANCE}-kv-oauth_kv`);
  const { requirements } = collectRequirements();

  const plan = await planResources(cf, requirements, 'update');
  assert.deepEqual(plan.blockers, []);
  assert.deepEqual(plan.create.map((c) => c.binding), ['OAUTH_KV'], '只有這一個該建');
  await applyResourcePlan(cf, plan);
  assert.deepEqual(cf.createdKv, ['OAUTH_KV']);
  assert.equal(cf.kv.size, 9, '刪掉一顆、補建一顆 → 還是 9 顆');
});

test('#97：全新帳號跑 init → 該建的都建（不會被 update 的停手規則卡住）', async () => {
  const cf = new FakeCloudflare({ nothingDeployed: true });
  cf.kv.clear(); cf.d1.clear(); cf.vectorize.length = 0;
  const { requirements } = collectRequirements();

  const plan = await planResources(cf, requirements, 'init');
  assert.deepEqual(plan.blockers, [], 'init 在空帳號上不該停手');
  await applyResourcePlan(cf, plan);
  assert.equal(cf.createdKv.length, REQUIRED_KV_NAMESPACES.length);
  assert.deepEqual(cf.createdD1, ['arcrun-kbdb']);
  assert.equal(cf.createdVectorize.length, 1);
});

test('#97：一邊已部署一邊沒有 → 跟著沿用同一顆，不要另外建一顆空的', async () => {
  const cf = new FakeCloudflare();
  cf.scripts.delete('arcrun-cypher-executor');   // cypher 還沒部（kbdb 已部，DB → d1id-kbdb）
  const { requirements, tomls } = collectRequirements();

  const plan = await planResources(cf, requirements, 'update');
  assert.deepEqual(plan.blockers, []);
  assert.ok(!plan.create.some((c) => c.kind === 'd1'), 'CREDENTIALS_DB 不該被當成新資源建一顆');

  const resolved = await applyResourcePlan(cf, plan);
  const bound = deployAndReadBindings(tomls, resolved);
  assert.equal(bound.get('arcrun-cypher-executor')!.get('CREDENTIALS_DB'), 'd1id-kbdb',
    'credential 目錄要跟 KBDB 在同一顆庫');
  assert.deepEqual(cf.createdD1, []);
});

test('#97：部署出去的 toml 不得殘留官方 prod 的資源 id（自架寫進官方庫 = 跨租戶外洩）', async () => {
  // repo 的 toml 裡 database_id 預設是官方 prod D1。舊版在「D1 解析失敗」時只是把它跳過不注入，
  // 於是自架用戶的 kbdb worker 就這樣綁著官方那顆庫部署出去。現在不是失敗就跳過，是整趟停手。
  const cf = new FakeCloudflare();
  const { requirements, tomls } = collectRequirements();
  const resolved = await applyResourcePlan(cf, await planResources(cf, requirements, 'update'));

  const OFFICIAL_D1 = '0c580910-e00b-4f8e-9c57-ac54ea52242f';
  for (const [rel, raw] of tomls) {
    const rendered = renderWranglerToml(raw, CTX, resolved);
    assert.doesNotMatch(rendered, new RegExp(OFFICIAL_D1), `${rel} 還帶著官方 prod D1 的 id`);
    assert.doesNotMatch(rendered, /REPLACE_WITH_REAL_KV_ID/, `${rel} 還留著占位 KV id`);
  }
});

// ═════════════════════════════════════════════════════════════════════════════
// 做法本身的看守：不准再出現「照名字 ensure」這種原語
// ═════════════════════════════════════════════════════════════════════════════

test('#97 紅線：cf-api 不得再提供任何「找不到同名就順手建一顆」的 ensure 原語', () => {
  const src = readFileSync(join(REPO, 'cli/src/lib/cf-api.ts'), 'utf8');
  assert.doesNotMatch(src, /\bensureKvNamespace\b|\bensureD1Database\b|\bensureVectorizeIndex\b/,
    'ensure* 是 #97 的凶器：把「查不到」當成「不存在」再自作主張新建。'
    + '要建資源一律先過 resource-resolver 的 planResources。');
});

test('#97 紅線：只有 resource-resolver 能決定「要不要建」，指令層不得自己呼叫 create*', () => {
  for (const rel of ['cli/src/commands/init.ts', 'cli/src/commands/update.ts']) {
    const src = readFileSync(join(REPO, rel), 'utf8');
    assert.doesNotMatch(src, /\.create(KvNamespace|D1Database|VectorizeIndex)\s*\(/,
      `${rel} 不該自己建資源——那樣就繞過了「先看已部署的 worker 綁著什麼」這道判斷。`);
  }
});

// ═════════════════════════════════════════════════════════════════════════════
// 底層零件
// ═════════════════════════════════════════════════════════════════════════════

test('parseWranglerRequirements：讀得出 script 名與三種資源綁定，且不把註解掉的區塊當需求', () => {
  const toml = [
    'name = "arcrun-kbdb"  # 註解不影響',
    '',
    '[[d1_databases]]',
    'binding = "DB"',
    'database_name = "arcrun-kbdb"',
    'database_id = "placeholder"',
    '',
    '[vars]',
    'ENVIRONMENT = "production"',
    '',
    '# [[vectorize]]',
    '# binding = "VECTORIZE"',
    '# index_name = "arcrun-kbdb-embed-m3"',
  ].join('\n');
  const r = parseWranglerRequirements(toml);
  assert.equal(r.script, 'arcrun-kbdb');
  assert.deepEqual(r.bindings, [{ kind: 'd1', binding: 'DB', createName: 'arcrun-kbdb' }]);
});

test('KV 沒有 title 欄位 → 真要新建時用 binding 名', () => {
  const r = parseWranglerRequirements('name = "w"\n[[kv_namespaces]]\nbinding = "WEBHOOKS"\nid = "x"');
  assert.deepEqual(r.bindings, [{ kind: 'kv_namespace', binding: 'WEBHOOKS', createName: 'WEBHOOKS' }]);
});

test('注入是照 binding 對號，不是盲換「檔案裡第一個 database_id」', () => {
  const cypher = readFileSync(join(REPO, 'cypher-executor/wrangler.toml'), 'utf8');
  const resolved = new Map<string, ResolvedResource>([
    [bindingKey('d1', 'CREDENTIALS_DB'), { kind: 'd1', binding: 'CREDENTIALS_DB', value: 'MINE', origin: 'adopted' }],
    [bindingKey('kv_namespace', 'WEBHOOKS'), { kind: 'kv_namespace', binding: 'WEBHOOKS', value: 'KV-MINE', origin: 'adopted' }],
  ]);
  const out = renderWranglerToml(cypher, CTX, resolved);
  const bound = parseWranglerRequirements(out);
  assert.ok(bound.bindings.some((b) => b.binding === 'CREDENTIALS_DB'));
  assert.match(out, /binding = "CREDENTIALS_DB"\ndatabase_name = "arcrun-kbdb"\ndatabase_id = "MINE"/);
  assert.match(out, /binding = "WEBHOOKS"\nid = "KV-MINE"/);
  // 沒被解析到的綁定不能被亂改（EXEC_CONTEXT 這次沒進 resolved）
  assert.match(out, /binding = "EXEC_CONTEXT"\nid = "616967a852eb450a8c01731f71ac8edd"/);
});

test('renderWranglerToml 帶空 map = 預覽：解析看到的 binding 與注入後的完全一致', () => {
  for (const rel of WORKER_TOMLS) {
    const raw = readFileSync(join(REPO, rel), 'utf8');
    const preview = parseWranglerRequirements(renderWranglerToml(raw, CTX, new Map()));
    const resolved = new Map<string, ResolvedResource>(
      preview.bindings.map((b) => [
        bindingKey(b.kind, b.binding),
        { kind: b.kind, binding: b.binding, value: `v-${b.binding}`, origin: 'adopted' as const },
      ]),
    );
    const after = parseWranglerRequirements(renderWranglerToml(raw, CTX, resolved));
    assert.deepEqual(
      after.bindings.map((b) => `${b.kind}:${b.binding}`).sort(),
      preview.bindings.map((b) => `${b.kind}:${b.binding}`).sort(),
      `${rel}: 預覽與實際注入看到的綁定必須一致`,
    );
  }
});

test('repo 的 toml 綁定總集合 = REQUIRED_KV_NAMESPACES（漏綁會讓某顆 worker 部署失敗）', () => {
  const { requirements } = collectRequirements();
  const kv = [...new Set(requirements.filter((r) => r.kind === 'kv_namespace').map((r) => r.binding))];
  assert.deepEqual(kv.sort(), [...REQUIRED_KV_NAMESPACES].sort());
});

// ═════════════════════════════════════════════════════════════════════════════
// Arcrun#123 —— 「上一次裝到一半死掉」的帳號，再裝一次要能成功
//
// 這一格與 #97 的差別：#97 是「worker 綁著資源，卻被重新綁到新建的空殼」（會弄丟資料）；
// #123 是「資源建好了、worker 一顆都還沒部署」——**沒有任何綁定可以當事實**，
// 而帳號上偏偏已經有一批同名資源 ⇒ 舊規則判「可以新建」⇒ CF 拒絕 ⇒ 這個帳號從此裝不起來。
//
// 封測者 1.4.45 實撞。逃過驗證的原因：我們只測乾淨帳號與完整安裝。
// ═════════════════════════════════════════════════════════════════════════════

/**
 * 安裝器那條路的需求：`createName` 是安裝器**自己替這台實例算出來的**
 * （`arcrun-rag-<slugFromEmail(email)>-…`）⇒ 有資格聲明 createNameIsOurs。
 * 對照 collectRequirements()（走 toml，createName 是裸 binding 名 ⇒ 不得聲明）。
 */
function installerRequirements(claimOwnership = true): BindingRequirement[] {
  const own = claimOwnership ? { createNameIsOurs: true } : {};
  const out: BindingRequirement[] = [];
  for (const { requirements } of [collectRequirements()]) {
    for (const r of requirements) {
      if (r.kind === 'kv_namespace') {
        out.push({ ...r, createName: `arcrun-rag-${INSTANCE}-kv-${r.binding.toLowerCase()}`, ...own });
      } else if (r.kind === 'd1') {
        out.push({ ...r, createName: `arcrun-rag-${INSTANCE}-kbdb`, ...own });
      } else {
        out.push({ ...r, createName: `arcrun-rag-${INSTANCE}-embed`, ...own });
      }
    }
  }
  return out;
}

test('#123 ①：上次裝到一半死掉的帳號 → 接回上次留下的那批，一顆都不必新建', async () => {
  const cf = new FakeCloudflare({ nothingDeployed: true });   // 資源在、worker 一顆都沒有
  const kvBefore = cf.kv.size;

  const plan = await planResources(cf, installerRequirements(), 'init');
  assert.deepEqual(plan.blockers, [], '半殘帳號不該有 blocker——用戶只要再按一次就該裝得起來');
  assert.deepEqual(plan.create, [], '一顆都不該新建');
  assert.ok(plan.adopt.length > 0 && plan.adopt.every((a) => a.reclaimed === true),
    '全部都是「接回上次留下的」');

  const resolved = await applyResourcePlan(cf, plan);
  assert.deepEqual(cf.createdKv, [], '不該新建任何 KV');
  assert.deepEqual(cf.createdD1, [], '不該新建任何 D1');
  assert.equal(cf.kv.size, kvBefore, '帳號上的顆數不變（沒有再留下一批孤兒）');

  // 綁到的是上次建的那幾顆本尊
  assert.equal(resolved.get(bindingKey('kv_namespace', 'WEBHOOKS'))!.value, 'kvid-webhooks');
  assert.equal(resolved.get(bindingKey('d1', 'DB'))!.value, 'd1id-kbdb');
  assert.equal(resolved.get(bindingKey('kv_namespace', 'WEBHOOKS'))!.origin, 'adopted',
    'origin 維持 adopted——呼叫端既有的 adopted/created 統計不會漏數');
});

test('#123 ①對照組：修好之前，同一個帳號會被 CF 用「title already exists」擋死', async () => {
  const cf = new FakeCloudflare({ nothingDeployed: true });
  // 不聲明所有權 ⇒ 走的是修好之前的判斷（「沒人綁著就可以新建」）。
  // 舊版會直接送 POST → 撞 CF 的同名限制；新版在 plan 階段就停手，兩者都裝不起來，
  // 差別在**新版一顆資源都不會被建出來**，而且訊息說得出人話。
  const plan = await planResources(cf, installerRequirements(false), 'init');
  assert.ok(plan.blockers.length > 0, '證明不了是自己的 → fail-closed 停手');
  assert.match(plan.blockers.join('\n'), /RES-NAME-TAKEN/, '要給得出可回報的錯誤碼');
  assert.doesNotMatch(plan.blockers.join('\n'), /後台|dashboard/,
    '不准叫使用者自己去 Cloudflare 後台處理（#121／D88）');
  await assert.rejects(() => applyResourcePlan(cf, plan), ResourcePlanBlocked);
  assert.deepEqual(cf.createdKv, [], '被擋下時一顆都不能被建出來');
});

test('#123 ②紅線：名字真的不是我們的（走 toml 的裸 binding 名）→ 停手，不接管也不新建', async () => {
  const cf = new FakeCloudflare({ nothingDeployed: true });
  await cf.createKvNamespace('WEBHOOKS');   // 使用者自己建的、剛好叫這個名字
  cf.createdKv.length = 0;
  const { requirements } = collectRequirements();   // createName = 裸 binding 名，不得聲明所有權

  const plan = await planResources(cf, requirements, 'init');
  assert.ok(plan.blockers.length > 0, '撞到不能證明是我們的同名資源 → 停手');
  assert.ok(!plan.create.some((c) => c.binding === 'WEBHOOKS'), 'WEBHOOKS 不准被排進「要新建」');
  await assert.rejects(() => applyResourcePlan(cf, plan), ResourcePlanBlocked);
  assert.deepEqual(cf.createdKv, [], '一顆都沒建');
});

test('#123 ③：全新帳號照舊建得出整套（D82 第一步不可退化）', async () => {
  const cf = new FakeCloudflare({ nothingDeployed: true });
  cf.kv.clear(); cf.d1.clear(); cf.vectorize.length = 0;   // 真正的空帳號

  const plan = await planResources(cf, installerRequirements(), 'init');
  assert.deepEqual(plan.blockers, [], '全新帳號不該有 blocker');
  assert.ok(plan.adopt.length === 0, '全新帳號沒有東西可沿用');
  const resolved = await applyResourcePlan(cf, plan);
  assert.equal(cf.createdKv.length, REQUIRED_KV_NAMESPACES.length, '9 顆 KV 全部建出來');
  assert.equal(cf.createdD1.length, 1, 'D1 建一顆（兩個綁定共用）');
  assert.ok([...resolved.values()].every((r) => r.origin === 'created'), '全部都是新建的');
});

test('CfAccountClient.getScriptBindings：404 = 還沒部署；其他錯誤要 throw（不能當成「沒有綁」）', async () => {
  const orig = globalThis.fetch;
  try {
    globalThis.fetch = (async () =>
      new Response(JSON.stringify({ success: false, errors: [{ message: 'not found' }] }), { status: 404 })
    ) as typeof fetch;
    const cf = new CfAccountClient('a', 't');
    assert.deepEqual(await cf.getScriptBindings('nope'), { deployed: false, bindings: [], vars: {} });

    globalThis.fetch = (async () =>
      new Response(JSON.stringify({ success: false, errors: [{ message: 'boom' }] }), { status: 500 })
    ) as typeof fetch;
    await assert.rejects(() => new CfAccountClient('a', 't').getScriptBindings('x'), /boom/);
  } finally {
    globalThis.fetch = orig;
  }
});

test('CfAccountClient.getScriptBindings：讀得懂 CF 回的 kv/d1/vectorize 三種綁定形狀', async () => {
  const orig = globalThis.fetch;
  try {
    globalThis.fetch = (async () => new Response(JSON.stringify({
      success: true,
      result: {
        bindings: [
          { type: 'kv_namespace', name: 'WEBHOOKS', namespace_id: 'kv1' },
          { type: 'd1', name: 'DB', id: 'db1' },
          { type: 'vectorize', name: 'VECTORIZE', index_name: 'idx1' },
          { type: 'plain_text', name: 'ENVIRONMENT', text: 'production' },
          { type: 'service', name: 'SVC_SET', service: 'arcrun-set' },
        ],
      },
    }), { status: 200 })) as typeof fetch;
    const res = await new CfAccountClient('a', 't').getScriptBindings('arcrun-cypher-executor');
    assert.equal(res.deployed, true);
    assert.deepEqual(res.bindings, [
      { kind: 'kv_namespace', binding: 'WEBHOOKS', value: 'kv1' },
      { kind: 'd1', binding: 'DB', value: 'db1' },
      { kind: 'vectorize', binding: 'VECTORIZE', value: 'idx1' },
    ]);
    // #106：plain_text 也要收下來（service 這種不認得的仍略過）。
    assert.deepEqual(res.vars, { ENVIRONMENT: 'production' });
  } finally {
    globalThis.fetch = orig;
  }
});
