/**
 * targets.mjs — 部署目標的唯一讀取點（deploy.mjs 與 verify-live.mjs 共用）。
 *
 * 存在的理由：宣告值（deploy.targets.json）只准被解讀一次。
 * 「部署時印在終端機的值」「寫進產物的值」「事後驗線上的值」若各自去讀、各自算，
 * 三者就會漂移——2026-08-08 那場事故的形狀正是「印的是 A、推的是 B」。
 * 這支把「一個目標展開成期望的產物長相」定死成一個函式，三邊共用同一個答案。
 *
 * 🔴 2026-08-08 第二層（leo：「已經發生過一次這個錯誤，把舊版界面上到 prod，
 *    你要確定不可再犯」）：組態對 ≠ 世代對。
 *    一個網址可以 apiBase／profile 全部正確，卻對外展示一套早就被淘汰的介面，
 *    而所有只驗組態的檢查都說它綠。故本檔另外定義「世代指紋」（見下半段）：
 *    把「線上這一份是不是當代的」變成一個可機械比對的值。
 */
import { createHash } from 'node:crypto';
import { cpSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';

export const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
export const PUBLIC_DIR = join(ROOT, 'public');

export function loadTargets() {
  const raw = JSON.parse(readFileSync(join(ROOT, 'deploy.targets.json'), 'utf8'));
  const profiles = raw._profiles;
  if (!profiles) throw new Error('deploy.targets.json 缺 _profiles（profile → views/home 對照）');
  const names = Object.keys(raw).filter((k) => !k.startsWith('_'));
  const active = names.filter((n) => !raw[n].frozen);
  return { raw, profiles, names, active };
}

export function resolveTarget(name) {
  const { raw, profiles, names } = loadTargets();
  const t = raw[name];
  if (!t) {
    const err = new Error(`未知的部署目標："${name}"。可用：${names.join(' / ')}`);
    err.usage = true;
    throw err;
  }
  // 凍結目標：連讀都不准碰（frozen.reason 說明是誰、何時、為什麼）。
  // 這不是「壞掉所以跳過」，是「這個帳號的資源不歸我們動」——工具自己守，不靠人記得。
  if (t.frozen) return { name, ...t, frozen: t.frozen, views: profiles[t.profile]?.views, home: profiles[t.profile]?.home };
  const p = profiles[t.profile];
  if (!p) {
    throw new Error(
      `目標 ${name} 的 profile="${t.profile}" 在 _profiles 裡沒有定義（可用：${Object.keys(profiles).join(' / ')}）。` +
        '\n宣告了一個沒人知道怎麼落地的 profile ⇒ 拒絕部署，不要猜。',
    );
  }
  if (!t.apiBase) throw new Error(`目標 ${name} 沒有 apiBase——空值會讓前端安靜地連不上，拒絕部署。`);
  if (!t.accountId) throw new Error(`目標 ${name} 沒有 accountId——不指定帳號可能部到別人的站上，拒絕部署。`);
  if (!Array.isArray(t.verifyUrls) || t.verifyUrls.length === 0) {
    throw new Error(`目標 ${name} 沒有 verifyUrls——沒有對外網址就無法驗「站上跑的＝宣告的」，拒絕部署。`);
  }
  return { name, ...t, views: p.views, home: p.home };
}

/** 這個目標「應該長成什麼樣」——產物閘與線上閘都比對這一份。 */
export function expected(t) {
  return {
    configJs: configJsFor(t),
    apiBase: t.apiBase,
    viewsLine: `  var VIEWS = ${JSON.stringify(t.views)};`,
    homeLine: `  var HOME = ${JSON.stringify(t.home)};`,
  };
}

export function configJsFor(t) {
  return (
    '// 由 console-ui/scripts/deploy.mjs 於部署時依 deploy.targets.json 產生——請勿手改，也不進 git。\n' +
    `// 目標：${t.name}（${t.description}）\n` +
    `window.ARCRUN_CONFIG = { apiBase: ${JSON.stringify(t.apiBase)} };\n`
  );
}

/** 從 config.js 的文字裡取出 apiBase（線上/產物共用同一個解析法）。 */
export function parseApiBase(text) {
  const m = text.match(/apiBase\s*:\s*"([^"]*)"/);
  return m ? m[1] : null;
}

// ─────────────────────────────────────────────────────────────────────────────
// 世代指紋（2026-08-08 第二層）
//
// 問題：verify-live 原本只驗組態（apiBase / VIEWS / HOME）。實測當天三個對外網址
//       這三項全綠，但線上跑的是 2026-07-22 那一代的 portal（82,911 bytes、
//       金色 serif「Arcrun」品牌、Songti 12 處），repo 是 343,969 bytes 的
//       「arc >> run」新代——**組態全對、介面整整落後半個月，機械檢查一片綠**。
//
// 判準：「線上這一份，是不是我們手上這一份？」不加解釋、不留模糊地帶——
//       逐一抓下線上資產、遮掉「本來就該隨部署目標不同」的那幾行，其餘按位元組比對。
//
// 為什麼是位元組而不是「找幾個關鍵字」：
//   關鍵字清單要人維護，而人只會在「這次剛好想到」時更新它。舊世代之所以能無聲上線，
//   正是因為沒有人記得去更新那張清單。位元組比對不需要任何人記得任何事：
//   repo 改了一個字，指紋就不同，線上沒跟上就是 ❌。
//
// 誠實的 trade-off（mindset §7，不假裝完美）：
//   ① 只要 repo 動過而還沒部署，這個檢查就會說「線上落後」——那是**正確的**，
//      因為那時線上確實不是當代的。它會吵，但吵的是真的。
//   ② 若哪天 CF 邊緣開始改寫 HTML（Rocket Loader 之類），會出現假 ❌。
//      2026-08-08 實測 mira.uncle6.me 與 pages.dev 回傳位元組完全相同（sha 一致），
//      證明目前沒有改寫。真出現時它會大聲壞掉、有人來查——
//      **假 ❌ 的代價遠低於假 ✅**（假 ✅ 就是這次事故本身）。
// ─────────────────────────────────────────────────────────────────────────────

/** 納入世代指紋的資產：file＝public/ 底下的路徑，urlPath＝線上要抓的位址。 */
export const GENERATION_ASSETS = [
  { file: 'index.html', urlPath: '/' },
  { file: 'portal/index.html', urlPath: '/portal/' },
  { file: 'console/index.html', urlPath: '/console/' },
  { file: 'favicon.svg', urlPath: '/favicon.svg' },
];

/**
 * 「本來就該隨部署目標不同」的行——比世代時遮掉，否則個人版與企業版永遠指紋不同。
 * 遮的只有這兩行；其餘全部按原樣比對。
 * config.js 整支不納入世代（它是純產物，由 apiBase 那一項單獨驗）。
 */
const TARGET_DEPENDENT_LINES = [
  { file: 'console/index.html', re: /^[ \t]*var VIEWS = .*$/m, tag: '«VIEWS：由部署目標決定»' },
  { file: 'console/index.html', re: /^[ \t]*var HOME = .*$/m, tag: '«HOME：由部署目標決定»' },
];

/** 遮掉目標相依的行。抓不到就原樣回傳（線上是舊世代時本來就可能沒有那幾行 → 該判 ❌）。 */
export function maskTargetValues(file, bytes) {
  const rules = TARGET_DEPENDENT_LINES.filter((r) => r.file === file);
  if (!rules.length) return bytes;
  let text = Buffer.from(bytes).toString('utf8');
  for (const r of rules) text = text.replace(r.re, r.tag);
  return Buffer.from(text, 'utf8');
}

export function sha256(bytes) {
  return createHash('sha256').update(bytes).digest('hex');
}

/**
 * 由「檔名 → 位元組（抓不到給 null）」算出世代指紋。
 * @param {Array<{file:string, bytes:Buffer|null}>} entries
 */
export function fingerprintOf(entries) {
  const assets = {};
  const lines = [];
  for (const { file, bytes } of entries) {
    if (bytes == null) {
      assets[file] = { sha: null, size: null, missing: true };
      lines.push(`${file}\tMISSING`);
      continue;
    }
    const masked = maskTargetValues(file, bytes);
    const sha = sha256(masked);
    assets[file] = { sha, size: Buffer.from(bytes).length, missing: false };
    lines.push(`${file}\t${sha}`);
  }
  return { assets, digest: sha256(Buffer.from(lines.join('\n'), 'utf8')) };
}

/** repo（或某個產物目錄）現在這一代長什麼樣。這就是「當代」的定義。 */
export function generationOfDir(dir = PUBLIC_DIR) {
  return fingerprintOf(
    GENERATION_ASSETS.map(({ file }) => {
      let bytes = null;
      try {
        bytes = readFileSync(join(dir, file));
      } catch {
        bytes = null;
      }
      return { file, bytes };
    }),
  );
}

// ─────────────────────────────────────────────────────────────────────────────
// 產物：把宣告值真的寫進去（e730b3f 標的 WIP，本次收掉）
// ─────────────────────────────────────────────────────────────────────────────

/**
 * 依目標把 public/ 展開成「要推上去的那一份」。
 * 🔴 覆寫沒命中就中止——宣告了卻沒寫進產物，正是這串事故的根。
 */
export function buildArtifact(t, outDir) {
  rmSync(outDir, { recursive: true, force: true });
  mkdirSync(outDir, { recursive: true });
  cpSync(PUBLIC_DIR, outDir, { recursive: true });

  const exp = expected(t);

  // ① config.js：產物，不是原始碼（public/ 裡不留）
  writeFileSync(join(outDir, 'config.js'), exp.configJs, 'utf8');

  // ② console 的 VIEWS/HOME：public/ 裡那兩行只是本機 preview 的預設值
  const consolePath = join(outDir, 'console', 'index.html');
  let html = readFileSync(consolePath, 'utf8');
  for (const [re, line, what] of [
    [/^[ \t]*var VIEWS = .*$/m, exp.viewsLine, 'VIEWS'],
    [/^[ \t]*var HOME = .*$/m, exp.homeLine, 'HOME'],
  ]) {
    if (!re.test(html)) {
      throw new Error(
        `產物覆寫沒命中：console/index.html 找不到 ${what} 那一行 ⇒ 中止部署。\n` +
          '（前端改版把那行換了寫法時會發生。宣告值寫不進去就不准推——這正是 2026-08-08 事故的形狀。）',
      );
    }
    html = html.replace(re, line);
  }
  writeFileSync(consolePath, html, 'utf8');

  return outDir;
}

/**
 * 產物閘：推之前，回頭讀「真的要被推上去的那些檔案」，確認＝宣告值。
 * 不看 deploy.mjs 自己印了什麼——只看磁碟上那份。
 */
export function assertArtifact(t, outDir) {
  const exp = expected(t);
  const problems = [];

  const cfg = readFileSync(join(outDir, 'config.js'), 'utf8');
  const gotApiBase = parseApiBase(cfg);
  if (gotApiBase !== t.apiBase) problems.push(`config.js 的 apiBase：宣告 ${t.apiBase}，產物 ${gotApiBase}`);

  const html = readFileSync(join(outDir, 'console', 'index.html'), 'utf8');
  const gotViews = html.match(/^[ \t]*var VIEWS = .*$/m)?.[0];
  const gotHome = html.match(/^[ \t]*var HOME = .*$/m)?.[0];
  if (gotViews !== exp.viewsLine) problems.push(`console VIEWS：宣告 ${exp.viewsLine.trim()}，產物 ${gotViews?.trim()}`);
  if (gotHome !== exp.homeLine) problems.push(`console HOME：宣告 ${exp.homeLine.trim()}，產物 ${gotHome?.trim()}`);

  // 世代閘（產物側）：注入不得改動世代相關位元組
  const src = generationOfDir(PUBLIC_DIR);
  const art = generationOfDir(outDir);
  if (src.digest !== art.digest) {
    problems.push(`產物世代指紋 ${art.digest.slice(0, 12)} ≠ public/ 的 ${src.digest.slice(0, 12)}（注入改到了不該改的位元組）`);
  }

  // 世代閘（內容側，沿用 t160 的文字指紋——擋「整份 public 被換成舊代」）
  //
  // 🔴 只看「使用者看得到的內容」，比對前先剝掉 HTML 註解。
  //    2026-08-08 實撞：原版直接對全文比對「登記新庫」，而 66f1b59（08-03）在 portal 裡
  //    加了一則**說明「已經把登記新庫拿掉了」的註解** ⇒ 這道閘從那天起每次都誤判，
  //    `npm run deploy:personal` 連續五天推不出去、而錯誤訊息說的是「你的 UI 是舊代」。
  //    ⇒ 手工維護的關鍵字清單會腐爛，這就是實例；世代的主判準因此改用位元組指紋，
  //      這道文字閘只留來擋「整份 public 被換成舊代」，且必須剝註解才不會自傷。
  const portalRaw = readFileSync(join(outDir, 'portal', 'index.html'), 'utf8');
  const portal = portalRaw.replace(/<!--[\s\S]*?-->/g, '');
  if (!portal.includes('不需要人工新增') || portal.includes('登記新庫')) {
    problems.push('portal/index.html 不是現行世代（可見內容缺「不需要人工新增」或仍有「登記新庫」）');
  }

  return { ok: problems.length === 0, problems, generation: art.digest };
}

/** 部署狀態記錄檔（只在「線上實測通過」之後才寫，見 deploy.mjs）。 */
export const STATE_FILE = join(ROOT, '.deploy-state.json');

export function readState() {
  try {
    return JSON.parse(readFileSync(STATE_FILE, 'utf8'));
  } catch {
    return {};
  }
}

export function writeState(name, record) {
  const state = readState();
  state[name] = record;
  writeFileSync(STATE_FILE, `${JSON.stringify(state, null, 2)}\n`, 'utf8');
}
