#!/usr/bin/env node
// @ts-check
/**
 * teardown-instance.mjs — 把一個 Cloudflare 帳號上的 Arcrun self-hosted 安裝拆乾淨。
 *
 * 🔴 這是內部工具，不是產品（leo 2026-08-14：「簡單先做出 uninstaller⋯先產出你自己可用的
 * 版本，在 youlin 使用，對外的後面提供」）。目的：讓「拆掉重裝」變成隨時做得到的事，
 * 配套 D82 三步驗收（Arcrun#119／#120）——沒有拆的能力，「重裝從乾淨狀態開始」就永遠測不到。
 *
 * 背景（Arcrun#120）：安裝器判斷 init/update 看的是**中心 KV** 的 `deployed:<accountId>:`，
 * 不是「帳號上現在有什麼」。只清帳號上的 worker/KV/D1/Vectorize、漏清中心那筆 ⇒ 重裝被判定
 * 「已經裝過」卡死。本工具因此分兩段：① 帳號側（Cloudflare 資源）② 中心側（安裝器的
 * INSTALLER_KV／PEER_INSTALLER_KV `deployed:` 紀錄）——兩段都做才算「不再殘留」。
 *
 * 安全設計（這是刪除工具，寫錯會毀掉別人的東西）：
 * 1. 帳號白名單——只認 ALLOWED_ACCOUNT_IDS 裡的帳號，其餘一律拒絕執行。
 * 2. 兩階段——預設 `plan`（唯讀，只印清單）；要真的刪必須 `apply --yes`。
 * 3. 歸屬判準——「這個帳號上的 worker 是不是這台 Arcrun 實例的」不是猜名字前綴，是
 *    「這個 worker 名字有沒有出現在本 repo（matrix/arcrun）任何 wrangler*.toml 的
 *    `name = "..."`」。不認得的 worker 一律「不動、列出來讓人看」，不會被當成「安全，可刪」。
 * 4. 共用資源保護——同一顆 KV/D1/Vectorize 若同時被「要刪的 worker」與「沒被列入要刪的
 *    worker」（不論是不認得的、還是被 --keep 排除的）綁著，一律排除、不刪、印警告。
 *    這是為了 youlin 帳號上可能同時存在多台實例（例：一次性測試用的
 *    arcrun-cypher-executor-freshtest）而設——見下「已知限制」。
 *
 * 已知限制（誠實列，不要假裝這個判準萬能）：
 * - 判準是「repo 認不認得這個 worker 名字」，不是「這個 worker 屬於哪一台實例」。同帳號上
 *   兩台同名慣例的實例（理論上不該發生，因為 worker 名字是全帳號唯一）用這個判準分不出來；
 *   能分的是「已知元件 vs 陌生 worker」以及「資源有沒有被別的 worker 共用」。
 * - `--keep <name,...>` 需要呼叫者自己知道帳號上有哪些 worker 該排除（本工具不會自動偵測
 *   「這個 worker 是不是正在被別人使用」——那是 main/coordinator 層級的協調，不是機器能判斷的）。
 * - 中心 KV 清理走 `wrangler kv key`，用的是本機既有的 wrangler OAuth 登入態（不是 CF API
 *   token），因為 INSTALLER_KV 住在另一個帳號（uncle6，官方安裝器所在），本工具沒有那把帳號
 *   的 API token。**2026-08-14 起機械強制**：`assertWranglerIsCentralAccount()` 在任何
 *   `central-plan`／`central-apply` 動作前跑 `wrangler whoami --json`，核對登入態看得到
 *   uncle6 帳號才放行，看不到就丟錯拒絕（D88「閘要長在機器上，不是長在誰的記性上」——
 *   原本這是操作者每次手動核對的紀律，不是結構保證，現在補成機械閘）。
 * - `apply` 對「worker 被別的 worker service binding 卡住刪不掉」**不會自動重試／拓樸排序**。
 *   這是 fail-safe（失敗就是刪不掉，不會刪錯東西），但需要操作者自己重跑 `apply` 到收斂。
 *   2026-08-14 兩次實測的收斂輪數：第一次（帳號上有 kbdb-graph-plugin 這類外部依賴殘留）
 *   跑了 2 輪（16 個失敗 → mcp 已刪，cypher-executor 解鎖 → 其餘 13 個跟著解鎖，僅
 *   arcrun-kbdb 卡在外部依賴，需另外處理）；第二次（帳號上沒有外部依賴殘留，純內部
 *   service binding 鏈）跑了 3 輪（16 個失敗 → cypher-executor 解鎖、13 個一次過 →
 *   只剩 array-ops 卡 cypher-executor 剛好晚一步刪掉 → 第 3 輪清空）。**下一個人看到
 *   第一輪一半失敗不是工具壞掉，是預期行為，重跑 `apply` 即可，通常 2–3 輪收斂。**
 * - 🔴 **偵測不到「不屬於這台實例的 worker 反過來依賴這台實例的 worker」這種形狀**
 *   （2026-08-14 實撞，兩層咬合）：`kbdb-graph-plugin`（不認得，正確被排除、不動）身上有
 *   service binding 指著 `arcrun-kbdb`（要刪的）。CF 因此拒絕刪 `arcrun-kbdb`——
 *   本工具當下**只會回報那次 DELETE 呼叫失敗**，不會主動告訴你「有一個外部依賴卡住了」，
 *   需要人自己讀錯誤訊息裡的 worker 名字去追。
 *   ⇒ **第二層**：kbdb-graph-plugin 被移除後 `arcrun-kbdb` 才刪得掉，但如果選擇「先清掉
 *   `arcrun-kbdb` 綁著的資源、留著這顆殼」這條路（半殘留），下次呼叫 `acr init`／`acr update`
 *   時，`shared/resource-rule` 的 fail-closed 保護（Arcrun#97 那道）會正確地發現
 *   「這顆 worker 的 binding 指向的資源已經找不到」而**拒絕重裝**（這是它該有的行為，不是
 *   bug；但代表**本工具留下的半殘留會讓下一步——重裝——卡住**，不是「拆一半也沒關係」）。
 *   ⇒ **結論**：本工具目前不會自動偵測或處理跨 worker 的依賴鏈；遇到「刪不掉、被誰誰誰的
 *   service binding 卡住」時，操作者要自己判斷那個「誰誰誰」是不是這台實例的一部分
 *   （是 → 一併排入拆除；不是 → 照紅線問人再決定要不要動它），**拆完後如果選了半殘留，
 *   要有心理準備下一步的 fail-closed 檢查會擋下重裝**。
 *
 * - 🔴 **只拆「有人綁著的」，看不見孤兒資源**：資源清單是從「要刪的 worker 的 binding」反推的，
 *   不是掃帳號。所以**沒有任何 worker 綁著的殘骸**（例：上一次安裝到一半死掉留下的 KV/D1）
 *   本工具**不會列、也不會刪**。這個取捨是刻意的——它同時是「不誤刪」的來源
 *   （2026-08-14 youlin 上的 `arcrun-rag-drill-a`／`drill-b` 兩顆演練用 D1 因此原封不動）——
 *   但代價是：拆完之後帳號上可能仍留著同名殘骸，重裝時撞 Arcrun#123（同名資源已存在）。
 *   ⇒ 拆完要重裝之前，**自己再列一次帳號**確認沒有同租戶前綴的孤兒。
 *
 * 用法：
 *   node scripts/teardown-instance.mjs plan  --account-id <id> --api-token <token> [--keep w1,w2]
 *   node scripts/teardown-instance.mjs apply --account-id <id> --api-token <token> [--keep w1,w2] --yes
 *   node scripts/teardown-instance.mjs central-plan  --account-id <id>
 *   node scripts/teardown-instance.mjs central-apply --account-id <id> --yes
 */

import { createCloudflareResourceApi } from '../shared/resource-rule/cf-resource-api.mjs';
import { execFileSync } from 'node:child_process';
import { readFileSync, readdirSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';

const __dirname = path.dirname(fileURLToPath(import.meta.url));
const REPO_ROOT = path.resolve(__dirname, '..');

// ── 紅線：帳號白名單（Cloudflare 帳號側）────────────────────────────────────────
// 只准對這裡列出的帳號動手。要加新帳號＝人類決定，不是機器自己判斷。
const ALLOWED_ACCOUNT_IDS = new Map([
  ['1129efd7df2e8899d537e9c8fbabb6cb', 'youlin（stage，可自由拆裝）'],
]);
// 明確擋掉的帳號（即使有人想放進 ALLOWED 也留這份對照，錯誤訊息才講得清楚是撞了哪條紅線）。
const FORBIDDEN_ACCOUNT_IDS = new Map([
  ['51a01bfa2665bd7bc3fd080dc40cf3e1', 'leo21c —— leo 正在上面工作，任何情況都不准動'],
]);

function assertAccountAllowed(accountId) {
  if (FORBIDDEN_ACCOUNT_IDS.has(accountId)) {
    throw new Error(
      `拒絕執行：帳號 ${accountId} 是明確禁止清單（${FORBIDDEN_ACCOUNT_IDS.get(accountId)}）。`
    );
  }
  if (!ALLOWED_ACCOUNT_IDS.has(accountId)) {
    throw new Error(
      `拒絕執行：帳號 ${accountId} 不在白名單（${[...ALLOWED_ACCOUNT_IDS.keys()].join(', ')}）。` +
        ` 這是刪除工具，新帳號要人類明確加進 ALLOWED_ACCOUNT_IDS 才能用，不接受呼叫端臨時指定。`
    );
  }
}

// ── 一、掃描本 repo，建立「已知 Arcrun worker 名字」清單 ────────────────────────
//
// 只掃 matrix/arcrun（本 repo）看不到 `arcrun-rag-ui`——那顆是 arcrun-rag 產品的前端，
// 由 products/arcrun-rag 的安裝器一起裝進同一個帳號（manifest.core 裡有它），但它的
// wrangler 設定不住在本 repo。單靠本 repo 掃描會把它誤判成「不認得，不動」，導致
// 「拆乾淨」漏掉一顆——因此額外把安裝器實際使用的 bundle manifest.json（manifest.core[].name）
// 併入已知清單。manifest.json 是公開端點、本工具的目的正是「重裝後長出一樣的東西」，
// 用它當第二個真相源與目的一致。抓不到（離線/端點掛了）就退回只用 repo 掃描，印警告，
// 不讓網路失敗擋住整個工具。

const DEFAULT_MANIFEST_URL =
  'https://cdn.jsdelivr.net/gh/youlinhsieh/arcrun-rag-bundles@20d4ad6900f51e02242815402a70890fc5b12712/manifest.json';

/** @returns {Promise<string[]>} */
async function fetchManifestCoreNames(manifestUrl = DEFAULT_MANIFEST_URL) {
  try {
    const res = await fetch(manifestUrl);
    if (!res.ok) throw new Error(`HTTP ${res.status}`);
    const manifest = await res.json();
    return (manifest.core ?? []).map((c) => c.name).filter(Boolean);
  } catch (e) {
    console.warn(`⚠️ 抓 bundle manifest 失敗（${e instanceof Error ? e.message : e}），只用本 repo 掃描的清單。`);
    return [];
  }
}

/** @returns {Set<string>} */
function scanKnownWorkerNames() {
  const names = new Set();
  const nameRe = /^\s*name\s*=\s*"([^"]+)"/gm;

  /** @param {string} dir */
  function walk(dir) {
    let entries;
    try {
      entries = readdirSync(dir, { withFileTypes: true });
    } catch {
      return;
    }
    for (const entry of entries) {
      const full = path.join(dir, entry.name);
      if (entry.isDirectory()) {
        if (entry.name === 'node_modules' || entry.name === '.git' || entry.name === '.wrangler') continue;
        walk(full);
      } else if (entry.isFile() && /^wrangler(\..+)?\.toml$/.test(entry.name)) {
        let content;
        try {
          content = readFileSync(full, 'utf8');
        } catch {
          continue;
        }
        for (const m of content.matchAll(nameRe)) {
          names.add(m[1]);
        }
      }
    }
  }
  walk(REPO_ROOT);
  return names;
}

// ── 二、帳號側 discovery ─────────────────────────────────────────────────────

/**
 * 列出帳號上所有 worker script 名字。
 *
 * 🔴 一定要翻頁。漏看一顆 worker 在這支工具裡是**兩層**傷害：
 * ① 那顆是實例的一部分卻沒被刪 ⇒ 拆不乾淨；
 * ② 更糟——那顆若綁著某顆 KV/D1，「共用資源保護」就看不到這個 owner，
 *    於是把還在用的資源判成「只有要刪的 worker 綁著」而**刪掉別人在用的東西**。
 * （同款坑 ops-facts 記過：`cf-resource-api.mjs` 寫死 per_page=100 又不翻頁。）
 */
async function listAllScripts(api) {
  const names = [];
  for (let page = 1; ; page++) {
    const { ok, result, error } = await api.cfRaw(`/workers/scripts?per_page=100&page=${page}`);
    if (!ok) throw new Error(`列 worker scripts 失敗（page ${page}）：${error}`);
    const batch = /** @type {Array<{id:string}>} */ (result) ?? [];
    for (const s of batch) names.push(s.id);
    if (batch.length < 100) break;
  }
  return names;
}

/**
 * 對帳號上每一顆 worker 查它的 binding。
 * 回傳 workerBindings（worker 名字對應到 binding 陣列）
 * 與 resourceOwners（資源 key 對應到綁著它的 worker 名字集合）。
 */
async function discoverAccount(api, allScripts) {
  const workerBindings = new Map();
  const resourceOwners = new Map();

  for (const name of allScripts) {
    const sb = await api.getScriptBindings(name);
    const bindings = sb.deployed ? sb.bindings : [];
    workerBindings.set(name, bindings);
    for (const b of bindings) {
      const key = `${b.kind}:${b.value}`;
      if (!resourceOwners.has(key)) resourceOwners.set(key, new Set());
      resourceOwners.get(key).add(name);
    }
  }
  return { workerBindings, resourceOwners };
}

/**
 * 算出拆除計畫。
 * @param {{accountId:string, apiToken:string, keep:string[]}} opts
 */
async function planTeardown({ accountId, apiToken, keep }) {
  assertAccountAllowed(accountId);
  const api = createCloudflareResourceApi({ accountId, apiToken });
  const knownNames = scanKnownWorkerNames();
  for (const n of await fetchManifestCoreNames()) knownNames.add(n);
  const allScripts = await listAllScripts(api);
  const { workerBindings, resourceOwners } = await discoverAccount(api, allScripts);

  const keepSet = new Set(keep);
  const knownOnAccount = allScripts.filter((n) => knownNames.has(n));
  const unknownOnAccount = allScripts.filter((n) => !knownNames.has(n));
  const workersToDelete = knownOnAccount.filter((n) => !keepSet.has(n));
  const workersKeptExplicit = knownOnAccount.filter((n) => keepSet.has(n));
  const toDeleteSet = new Set(workersToDelete);

  const resourcesToDelete = [];
  const resourcesExcludedShared = [];
  const seenResourceKeys = new Set();
  for (const name of workersToDelete) {
    for (const b of workerBindings.get(name) ?? []) {
      const key = `${b.kind}:${b.value}`;
      if (seenResourceKeys.has(key)) continue;
      seenResourceKeys.add(key);
      const owners = [...resourceOwners.get(key)];
      const sharedWithSurvivor = owners.some((o) => !toDeleteSet.has(o));
      if (sharedWithSurvivor) {
        resourcesExcludedShared.push({ ...b, key, owners });
      } else {
        resourcesToDelete.push({ ...b, key, owners });
      }
    }
  }

  return {
    accountId,
    accountLabel: ALLOWED_ACCOUNT_IDS.get(accountId),
    knownNames,
    allScripts,
    unknownOnAccount,
    workersToDelete,
    workersKeptExplicit,
    resourcesToDelete,
    resourcesExcludedShared,
  };
}

function printPlan(plan) {
  console.log(`\n=== 拆除計畫：${plan.accountId}（${plan.accountLabel}）===\n`);
  console.log(`帳號上共 ${plan.allScripts.length} 顆 worker。`);
  console.log(`\n【會被刪除的 worker】（${plan.workersToDelete.length} 顆——名字出現在本 repo wrangler*.toml，且沒被 --keep 排除）`);
  for (const n of plan.workersToDelete) console.log(`  - ${n}`);

  if (plan.workersKeptExplicit.length) {
    console.log(`\n【明確排除、保留的已知 worker】（${plan.workersKeptExplicit.length} 顆——用 --keep 指定）`);
    for (const n of plan.workersKeptExplicit) console.log(`  - ${n}`);
  }

  if (plan.unknownOnAccount.length) {
    console.log(`\n【不認得的 worker，不動】（${plan.unknownOnAccount.length} 顆——名字沒出現在本 repo 任何 wrangler*.toml）`);
    for (const n of plan.unknownOnAccount) console.log(`  - ${n}`);
  }

  console.log(`\n【會被刪除的資源】（${plan.resourcesToDelete.length} 個——只被「要刪的 worker」綁著）`);
  for (const r of plan.resourcesToDelete) {
    console.log(`  - [${r.kind}] ${r.value}（binding ${r.binding}，owner: ${r.owners.join(', ')}）`);
  }

  if (plan.resourcesExcludedShared.length) {
    console.log(`\n⚠️ 【排除、不刪的共用資源】（${plan.resourcesExcludedShared.length} 個——同時被保留中的 worker 綁著）`);
    for (const r of plan.resourcesExcludedShared) {
      console.log(`  - [${r.kind}] ${r.value}（binding ${r.binding}，owner: ${r.owners.join(', ')}）`);
    }
  }
  console.log('');
}

async function applyTeardown(plan, { accountId, apiToken }) {
  assertAccountAllowed(accountId);
  const api = createCloudflareResourceApi({ accountId, apiToken });
  const results = { workers: [], resources: [] };

  for (const name of plan.workersToDelete) {
    const { ok, status, error } = await api.cfRaw(`/workers/scripts/${encodeURIComponent(name)}`, {
      method: 'DELETE',
    });
    results.workers.push({ name, ok, status, error });
    console.log(ok ? `  ok 刪除 worker ${name}` : `  FAIL 刪除 worker ${name}（${status}）：${error}`);
  }

  for (const r of plan.resourcesToDelete) {
    let res;
    if (r.kind === 'kv_namespace') {
      res = await api.cfRaw(`/storage/kv/namespaces/${r.value}`, { method: 'DELETE' });
    } else if (r.kind === 'd1') {
      res = await api.cfRaw(`/d1/database/${r.value}`, { method: 'DELETE' });
    } else if (r.kind === 'vectorize') {
      res = await api.cfRaw(`/vectorize/v2/indexes/${encodeURIComponent(r.value)}`, { method: 'DELETE' });
    } else {
      res = { ok: false, status: 0, error: `未知資源類型 ${r.kind}` };
    }
    results.resources.push({ ...r, ok: res.ok, status: res.status, error: res.error });
    console.log(
      res.ok
        ? `  ok 刪除 ${r.kind} ${r.value}`
        : `  FAIL 刪除 ${r.kind} ${r.value}（${res.status}）：${res.error}`
    );
  }

  return results;
}

// ── 三、中心 KV（安裝器 deployed 紀錄）清理 ────────────────────────────────────
// 這兩顆 KV 住在 uncle6 帳號（官方安裝器 arcrun-installer 所在），不是 youlin 的帳號。
// 沒有這顆帳號的 CF API token，走本機已登入的 wrangler OAuth。
//
// 🔴 2026-08-14 leo 審核裁示（D88「閘要長在機器上，不是長在誰的記性上」）：這裡原本
// 靠操作者自己每次手動 `wrangler whoami` 核對——那個核對動作是對的，只是長錯地方
// （長在人身上）。失敗模式是最壞的一種：wrangler 的 OAuth 登入態如果哪天換成別的帳號，
// 會**靜默**對錯的帳號動中心 KV，沒有任何東西會叫。帳號側（youlin）有 ALLOWED_ACCOUNT_IDS
// 白名單擋，中心側原本完全沒有——這個不對稱本身就是設計缺口。
//
// ⚠️ 2026-08-14 稍晚訂正：上面那句原本寫「現在補齊」，**但當時只補了一半**——
// 補的是「以誰的身分動手」（wrangler 身分），沒補「動的是誰的紀錄」（accountId 白名單）。
// 實測證明破口是真的：`central-plan --account-id 51a01bfa…`（leo21c）一路暢通，
// 直接列出 `deployed:51a01bfa…:leo21c`，再加 `--yes` 就會刪掉 leo 的安裝紀錄。
// ⇒ 現在 planCentralCleanup() 兩道都跑：assertAccountAllowed ＋ assertWranglerIsCentralAccount。
// 📌 教訓：**「補齊了」這句話要用實測講，不要用註解講**——註解說補齊，程式只補一半，
//    而後來讀的人（包括我）會相信註解。
// assertWranglerIsCentralAccount() 在任何中心 KV 讀寫前跑，核對 wrangler 現在的 OAuth
// 登入態的帳號清單裡有沒有 CENTRAL_ACCOUNT_ID，沒有就直接丟錯、不放行。

const CENTRAL_KV = {
  prod: { id: '2ff9a845e7f646dab7b1e3dd6707b77f', label: 'INSTALLER_KV（prod 通道）' },
  staging: { id: '750c23faf0794d3db317c32f596df9a9', label: 'PEER_INSTALLER_KV（staging 通道，t157 雙通道問題）' },
};
const CENTRAL_ACCOUNT_ID = '58309bb90fd93ad6d0fe0aae99170e9d'; // uncle6
const CENTRAL_ACCOUNT_LABEL = "uncle6.me@gmail.com's Account";

/** 核對本機 wrangler 的 OAuth 登入態確實看得到 uncle6 帳號，不對就丟錯、不放行任何中心 KV 操作。 */
function assertWranglerIsCentralAccount() {
  let out;
  try {
    out = execFileSync('npx', ['wrangler', 'whoami', '--json'], {
      cwd: REPO_ROOT,
      encoding: 'utf8',
      stdio: ['ignore', 'pipe', 'pipe'],
    });
  } catch (e) {
    throw new Error(
      `拒絕執行中心 KV 操作：無法確認 wrangler 登入身分（\`wrangler whoami --json\` 失敗：` +
        `${e instanceof Error ? e.message : e}）。這是刪除工具，身分核不對就不准動中心 KV。`
    );
  }
  const jsonStart = out.indexOf('{');
  let who;
  try {
    who = JSON.parse(jsonStart >= 0 ? out.slice(jsonStart) : out);
  } catch {
    throw new Error(`拒絕執行中心 KV 操作：解析 \`wrangler whoami --json\` 輸出失敗：${out.slice(0, 300)}`);
  }
  if (!who.loggedIn) {
    throw new Error('拒絕執行中心 KV 操作：wrangler 目前未登入（loggedIn:false）。');
  }
  const ids = (who.accounts ?? []).map((a) => a.id);
  if (!ids.includes(CENTRAL_ACCOUNT_ID)) {
    throw new Error(
      `拒絕執行中心 KV 操作：wrangler 目前的 OAuth 登入態（${who.email ?? '未知 email'}）看得到的帳號是` +
        ` [${ids.join(', ') || '無'}]，裡面沒有中心帳號 ${CENTRAL_ACCOUNT_ID}（${CENTRAL_ACCOUNT_LABEL}）。` +
        ` 這代表你現在的 wrangler 登入態不是 uncle6——INSTALLER_KV／PEER_INSTALLER_KV 只住在那個帳號，` +
        ` 繼續執行極可能是對錯的帳號動手（或單純連不到）。請先 \`npx wrangler login\` 切回 uncle6 帳號再重跑。`
    );
  }
}

function wranglerKvList(namespaceId, prefix) {
  const out = execFileSync(
    'npx',
    ['wrangler', 'kv', 'key', 'list', '--namespace-id', namespaceId, '--prefix', prefix, '--remote'],
    { cwd: REPO_ROOT, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }
  );
  const jsonStart = out.indexOf('[');
  const jsonText = jsonStart >= 0 ? out.slice(jsonStart) : out;
  try {
    return JSON.parse(jsonText);
  } catch {
    throw new Error(`解析 wrangler kv key list 輸出失敗：${out.slice(0, 500)}`);
  }
}

function wranglerKvDelete(namespaceId, key) {
  execFileSync(
    'npx',
    ['wrangler', 'kv', 'key', 'delete', '--namespace-id', namespaceId, key, '--remote'],
    { cwd: REPO_ROOT, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }
  );
}

function planCentralCleanup(accountId) {
  // 🔴 兩道，缺一不可，而且問的是**不同的問題**：
  //    ① assertAccountAllowed(accountId)：「我要清掉的是**誰的**紀錄」——
  //       中心 KV 裡每個帳號一筆，`deployed:<accountId>:`。少了這道，
  //       `central-apply --account-id <leo21c 的 id>` 會清掉 leo21c 的安裝紀錄，
  //       leo 下次重裝就會撞上 Arcrun#120 的死結。**帳號側擋得住 leo21c，中心側原本擋不住。**
  //       （2026-08-14 實測：`central-plan --account-id 51a01bfa…` 直接列出了
  //        `deployed:51a01bfa…:leo21c`，一路暢通。）
  //    ② assertWranglerIsCentralAccount()：「我現在**以誰的身分**在動中心 KV」（D88 那道）。
  //    ①問對象、②問身分。原本只有②，於是「白名單」只保護了帳號側的一半路徑。
  assertAccountAllowed(accountId);
  assertWranglerIsCentralAccount();
  const prefix = `deployed:${accountId}:`;
  const plan = {};
  for (const [chan, kv] of Object.entries(CENTRAL_KV)) {
    const keys = wranglerKvList(kv.id, prefix).map((k) => k.name);
    plan[chan] = { ...kv, prefix, keys };
  }
  return plan;
}

function printCentralPlan(plan) {
  console.log(`\n=== 中心 KV 拆除計畫（帳號 ${CENTRAL_ACCOUNT_ID}, uncle6）===\n`);
  for (const info of Object.values(plan)) {
    console.log(`【${info.label}】前綴 ${info.prefix}`);
    if (!info.keys.length) {
      console.log('  （沒有殘留紀錄）');
    } else {
      for (const k of info.keys) console.log(`  - ${k}`);
    }
  }
  console.log('');
}

function applyCentralCleanup(plan) {
  const results = {};
  for (const [chan, info] of Object.entries(plan)) {
    results[chan] = [];
    for (const k of info.keys) {
      try {
        wranglerKvDelete(info.id, k);
        console.log(`  ok 刪除 ${info.label} 的 ${k}`);
        results[chan].push({ key: k, ok: true });
      } catch (e) {
        console.log(`  FAIL 刪除 ${info.label} 的 ${k}：${e instanceof Error ? e.message : e}`);
        results[chan].push({ key: k, ok: false, error: String(e) });
      }
    }
  }
  return results;
}

// ── 四、CLI ────────────────────────────────────────────────────────────────

function parseArgs(argv) {
  const out = { _: [] };
  for (let i = 0; i < argv.length; i++) {
    const a = argv[i];
    if (a === '--yes') out.yes = true;
    else if (a === '--account-id') out.accountId = argv[++i];
    else if (a === '--api-token') out.apiToken = argv[++i];
    else if (a === '--keep') out.keep = argv[++i].split(',').map((s) => s.trim()).filter(Boolean);
    else out._.push(a);
  }
  return out;
}

async function main() {
  const args = parseArgs(process.argv.slice(2));
  const cmd = args._[0];

  if (cmd === 'plan' || cmd === 'apply') {
    if (!args.accountId || !args.apiToken) {
      console.error('需要 --account-id 與 --api-token');
      process.exit(1);
    }
    const plan = await planTeardown({ accountId: args.accountId, apiToken: args.apiToken, keep: args.keep ?? [] });
    printPlan(plan);
    if (cmd === 'plan') return;
    if (!args.yes) {
      console.error('apply 需要加 --yes（先看過上面的計畫再加這個 flag）。');
      process.exit(1);
    }
    console.log('=== 開始刪除 ===\n');
    const results = await applyTeardown(plan, { accountId: args.accountId, apiToken: args.apiToken });
    console.log('\n=== 完成 ===');
    console.log(JSON.stringify(results, null, 2));
    return;
  }

  if (cmd === 'central-plan' || cmd === 'central-apply') {
    if (!args.accountId) {
      console.error('需要 --account-id（要清哪個帳號的 deployed 紀錄）');
      process.exit(1);
    }
    const plan = planCentralCleanup(args.accountId);
    printCentralPlan(plan);
    if (cmd === 'central-plan') return;
    if (!args.yes) {
      console.error('central-apply 需要加 --yes。');
      process.exit(1);
    }
    console.log('=== 開始刪除中心紀錄 ===\n');
    const results = applyCentralCleanup(plan);
    console.log('\n=== 完成 ===');
    console.log(JSON.stringify(results, null, 2));
    return;
  }

  console.log(`用法：
  node scripts/teardown-instance.mjs plan          --account-id <id> --api-token <token> [--keep w1,w2]
  node scripts/teardown-instance.mjs apply         --account-id <id> --api-token <token> [--keep w1,w2] --yes
  node scripts/teardown-instance.mjs central-plan  --account-id <id>
  node scripts/teardown-instance.mjs central-apply --account-id <id> --yes`);
  process.exit(1);
}

main().catch((e) => {
  console.error('錯誤：', e instanceof Error ? e.message : e);
  process.exit(1);
});
