#!/usr/bin/env node
/**
 * build-ui-worker.mjs — 把 `console-ui/public` 內嵌成一顆單檔 worker（`arcrun-rag-ui`）。
 *
 * 🔴 2026-08-15（D91／Arcrun#125）：這支是**從 arcrun-rag 搬過來的**。
 *   leo 原話：「今天開始出貨一律不准在 arcrun rag 或任何別的地方 build，
 *   **這就是 arcrun 的專屬工作**。你告訴我要把 cypher 搬到 arcrun，我說好，
 *   結果搞到現在還用違反的方式。」
 *
 *   實況（2026-08-14 查）：五顆 tier2 worker 早就改成「向 Arcrun 取用成品」了
 *   （`build-worker-artifacts.mjs` → `.worker-builds/`），**只有這顆 UI 沒搬**——
 *   arcrun-rag 的 `installer/scripts/build-ui-bundle.mjs` 仍然自己讀本 repo 的
 *   `console-ui/public`、自己拼裝出 worker 原始碼。於是：
 *     · 用戶拿到的 portal 前端，其位元組沒有任何一個 Arcrun commit 說得出來源
 *     · 「成品只有一個產地」這句話對六顆零件裡的五顆成立，對第六顆不成立
 *   ⇒ 規則不是被誰違反，是**它從來沒有被機械驗證過**（同 D54／D64／D90 的形狀）。
 *
 *   搬過來之後，UI 與其他零件走同一條路：
 *     產地（本 repo）→ `.worker-builds/arcrun-rag-ui/worker.mjs` ＋ manifest 指紋
 *     → arcrun-rag 只做「複製 ＋ 算版本」，不再有任何一行產生 worker 原始碼。
 *
 * ⚠️ 內嵌邏輯與兩道閘（世代閘、前端 JS 語法閘）**原樣搬過來，一個字沒改**——
 *   它們驗的是本 repo 的 `console-ui/public`，本來就該長在來源這一側。
 *   唯一改動：產出物開頭那行註解改成指向本檔（不留「描述不存在機制的註解」）。
 *
 * 對外只有一個 API：`buildUiWorker({ uiDir })` → `{ source, fingerprint, fileCount }`。
 * 寫檔與 manifest 由 `build-worker-artifacts.mjs` 統一處理（產物格式與其他零件一致）。
 */
import { readdirSync, statSync, readFileSync, writeFileSync } from 'node:fs';
import { join, relative, extname } from 'node:path';
import { createHash } from 'node:crypto';

/**
 * @param {{ uiDir: string }} opts uiDir＝`console-ui/public` 的絕對路徑
 * @returns {Promise<{ source: string, fingerprint: string, fileCount: number }>}
 */
export async function buildUiWorker({ uiDir }) {
  const UI = uiDir;

  const TYPES = {
    '.html': 'text/html; charset=utf-8',
    '.js': 'application/javascript; charset=utf-8',
    '.mjs': 'application/javascript; charset=utf-8',
    '.css': 'text/css; charset=utf-8',
    '.json': 'application/json; charset=utf-8',
    '.svg': 'image/svg+xml',
    '.png': 'image/png',
    '.ico': 'image/x-icon',
  };
  const TEXT_EXT = new Set(['.html', '.js', '.mjs', '.css', '.json', '.svg']);
  // config.js 由 worker 動態產生（apiBase 注入），不吃靜態版
  const SKIP = new Set(['/config.js']);

  // ⚠️ 一定要 sort()：指紋是 JSON.stringify(files) 算的，鍵的順序＝目錄列舉順序。
  //    不排序的話同樣的內容在不同機器會得到不同指紋（B 版就漏了這一步）。
  function walk(dir, base, out) {
    for (const name of readdirSync(dir).sort()) {
      const p = join(dir, name);
      if (statSync(p).isDirectory()) { walk(p, base, out); continue; }
      const key = '/' + relative(base, p).split('\\').join('/');
      if (SKIP.has(key)) continue;
      const ext = extname(name);
      const type = TYPES[ext];
      if (!type) continue; // 未知型別不進 bundle
      if (TEXT_EXT.has(ext)) out[key] = { type, b64: false, data: readFileSync(p, 'utf8') };
      else out[key] = { type, b64: true, data: readFileSync(p).toString('base64') };
    }
  }

  // ── 閘① t160 世代閘（B 版）─────────────────────────────────────────────
  // 打包前驗 portal 頁指紋——舊世代（缺「不需要人工新增」文案／含人工建庫「登記新庫」表單）
  // 直接拒打包。病史：t159 重打包吃了停在舊世代的分支 public/，把 leo 實例的 UI 打回
  // 被淘汰的人工登記典範。
  // 🔴 2026-08-02 修誤判：原本直接對整份原始碼 grep「登記新庫」，但**現行世代的 HTML
  //    註解裡就寫著「07-27 leo：拿掉『登記新庫』…」**——那是「已經拿掉了」的紀錄，
  //    是新世代的證據，卻被當成舊世代特徵 ⇒ 閘把對的東西擋下來。
  //    正解：**先剝掉註解再驗**，只看用戶真的會看到的 UI 內容。
  {
    const portalRaw = readFileSync(join(UI, 'portal', 'index.html'), 'utf8');
    const portal = portalRaw.replace(/<!--[\s\S]*?-->/g, '');
    if (!portal.includes('不需要人工新增') || portal.includes('登記新庫')) {
      console.error('✘ 世代閘：--ui 指向的 public 不是現行世代（缺「不需要人工新增」或含「登記新庫」）——拒絕打包舊 UI。');
      console.error(`   剝註解後：不需要人工新增=${portal.includes('不需要人工新增')}／登記新庫=${portal.includes('登記新庫')}`);
      process.exit(1);
    }
  }

  const files = {};
  walk(UI, UI, files);
  if (!files['/portal/index.html']) throw new Error(`/portal/index.html 不在 ${UI} — 路徑錯了？`);

  // ── 閘② t131 前端 JS 語法閘（A 版）────────────────────────────────────
  // 2026-07-29 事故：誤刪一個 `})();` ⇒ portal 白畫面，驗收沒抓到——
  // 因為測試只測後端、grep 只看字串，**沒有人驗過前端 JS 語法**。
  // 打包前用 node --check 驗每個 HTML 內嵌 script，語法錯就拒絕產出 bundle。
  {
    const { execFileSync } = await import('node:child_process');
    const { mkdtempSync } = await import('node:fs');
    const { tmpdir } = await import('node:os');
    const tmp = mkdtempSync(join(tmpdir(), 'uicheck-'));
    for (const [name, f] of Object.entries(files)) {
      if (!name.endsWith('.html') || f.b64) continue;
      const blocks = [...String(f.data).matchAll(/<script(?![^>]*\bsrc=)[^>]*>([\s\S]*?)<\/script>/g)];
      blocks.forEach((m, i) => {
        const jsPath = join(tmp, `${name.replace(/[^\w]/g, '_')}_${i}.js`);
        writeFileSync(jsPath, m[1]);
        try {
          execFileSync(process.execPath, ['--check', jsPath], { stdio: 'pipe' });
        } catch (e) {
          const msg = String(e.stderr || e.message).split('\n').slice(0, 6).join('\n');
          throw new Error(`❌ ${name} 的第 ${i + 1} 個 <script> 有語法錯，拒絕打包（會導致白畫面）：\n${msg}`);
        }
      });
    }
    console.log('✅ 前端 JS 語法閘通過');
  }

  // 🔴 2026-08-02（leo 實撞：「一樣從 install 安裝，為什麼會裝到錯誤版本？」）
  //    UI 內容指紋——**UI 過去沒有自己的版本號**，安裝器只能猜特徵
  //    （t168 猜「/favicon.svg 開頭是不是 <svg」），而那只能分辨「有沒有 favicon 路由」
  //    這一個世代差 ⇒ 舊 UI 早就有 favicon 路由，於是被判定「已是新版」
  //    ⇒ **整批跳過、UI 永遠不重推**，用戶重裝幾次都拿到舊前端。
  //    正解不是再想一個新特徵去猜（下個世代還會再猜錯），是**讓 UI 自己報版本**。
  const uiFingerprint = createHash('sha256').update(JSON.stringify(files)).digest('hex');

  const worker = `// arcrun-rag-ui — 用戶實例的 GUI（console-ui/public 內嵌；由 Arcrun scripts/build-ui-worker.mjs 產生，勿手改）
const FILES = ${JSON.stringify(files)};
const UI_FINGERPRINT = ${JSON.stringify(uiFingerprint)};
function b64ToBytes(b64) { const bin = atob(b64); const a = new Uint8Array(bin.length); for (let i = 0; i < bin.length; i++) a[i] = bin.charCodeAt(i); return a; }
export default {
  async fetch(request, env) {
    const url = new URL(request.url);
    let p = url.pathname;
    // 🔴 UI 自報版本（安裝器據此判斷要不要重推，取代「猜 favicon 特徵」）。
    //    比照 cypher 的 /health。舊世代的 UI 沒有這條路由 ⇒ SPA fallback 回 HTML
    //    ⇒ 安裝器解析 JSON 失敗＝判定為舊版＝重推。**天然向後相容，不需額外世代判斷。**
    //    bundle_version 由安裝器以 ARCRUN_BUNDLE_VERSION 注入（cypher /health 同一個值）；
    //    沒注入就回空字串，安裝器那端把空值一律當「舊的」處理。
    if (p === '/__version') {
      // 🔴 2026-08-08：多報 worker_subdomain / config_ok 兩格。
      //    這條路由是**安裝器唯一會去問實例的地方**（probeInstanceStale），
      //    以前它只報版本 ⇒「版本對、但 apiBase 空」的實例在安裝器眼中是「已是最新」
      //    ⇒ 永遠不會被重推＝壞掉的實例自己好不了。報了之後，安裝器把
      //    config_ok:false 一律當成 stale ⇒ **下一次安裝／更新就自動治好它**。
      return new Response(JSON.stringify({
        ok: true,
        ui_fingerprint: UI_FINGERPRINT,
        bundle_version: env.ARCRUN_BUNDLE_VERSION || '',
        worker_subdomain: env.WORKER_SUBDOMAIN || '',
        config_ok: Boolean(env.WORKER_SUBDOMAIN),
      }), {
        headers: { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' },
      });
    }
    // config.js 動態產生：apiBase 指向同帳號的 cypher（WORKER_SUBDOMAIN 由安裝器注入）
    //
    // 🔴 2026-08-08 事故（leo 的 youlin 實例整頁不能用，頂端紅字「設定檔沒載入」）：
    //    有人用 wrangler deploy 手動補這顆 worker ⇒ 繞過安裝器的變數注入
    //    ⇒ WORKER_SUBDOMAIN 空 ⇒ 這裡**安靜地**退成 apiBase:""（而且回 HTTP 200！）
    //    ⇒ 前端連不到自己的後端，但**部署是「成功」的**：worker 活著、curl 200、
    //      /__version 版本也對——只有真人打開瀏覽器才看得出壞了。
    //    正解不是「退一個合理的預設值」（沒有合理預設，猜錯更慘），
    //    是**大聲失敗**：回 500＋專用標頭，讓機器檢查一眼看穿。
    //    瀏覽器不執行非 2xx 的 <script>，所以用戶端行為與今天一致
    //    （portal 照樣顯示「設定檔沒載入」紅字），差別是**機器也看得見了**。
    //    no-store：這頁被邊緣快取過就會「修好了還是壞的」（08-08 實撞，害人誤判修復失敗）。
    if (p === '/config.js') {
      const sub = env.WORKER_SUBDOMAIN || '';
      if (!sub) {
        return new Response(CONFIG_MISSING_BODY, {
          status: 500,
          headers: {
            'content-type': TYPES_JS,
            'cache-control': 'no-store',
            'x-arcrun-config-error': 'WORKER_SUBDOMAIN-missing',
          },
        });
      }
      const apiBase = \`https://arcrun-cypher-executor.\${sub}.workers.dev\`;
      return new Response(\`window.ARCRUN_CONFIG = { apiBase: \${JSON.stringify(apiBase)} };\`, {
        headers: { 'content-type': TYPES_JS, 'cache-control': 'no-store' },
      });
    }
    if (p.endsWith('/')) p += 'index.html';
    let f = FILES[p] || FILES[p + '/index.html'] || FILES[p + '.html'];
    if (!f && !p.includes('.')) f = FILES[p + '/index.html'];
    if (!f) { p = '/portal/index.html'; f = FILES[p]; } // SPA fallback→portal
    if (!f) return new Response('not found', { status: 404 });
    const body = f.b64 ? b64ToBytes(f.data) : f.data;
    return new Response(body, { headers: { 'content-type': f.type, 'cache-control': 'no-cache' } });
  },
};
const TYPES_JS = 'application/javascript; charset=utf-8';
const CONFIG_MISSING_BODY = [
  '/* ============================================================',
  '   arcrun-rag-ui：WORKER_SUBDOMAIN 這個變數沒有被注入。',
  '   算不出 apiBase ＝ 前端不知道自己的後端在哪 ＝ 整個 portal 不能用。',
  '',
  '   成因幾乎一定是：這顆 worker 被手動 wrangler deploy 部署，繞過了注入。',
  '   修法（擇一）：',
  '     1. 重跑一次安裝器（它會注入，且會把這顆判定為 stale 而重推）',
  '     2. node installer/scripts/deploy-ui.mjs --subdomain <你的 workers.dev 子網域>',
  '   ============================================================ */',
  'throw new Error("arcrun-rag-ui: WORKER_SUBDOMAIN 未注入，apiBase 無法產生");',
].join('\\n');
`;
  return { source: worker, fingerprint: uiFingerprint, fileCount: Object.keys(files).length };
}
