/**
 * 認證儲存（D61：認證與資料分離）— 門鎖不住在知識資料庫裡
 *
 * leo 2026-08-10 下令（ADR D61 / Leo/arcrun-rag#55）：
 *   「登入認證資料要分離⋯⋯**就算只有我一個人存在單獨的 json 檔也好**，
 *     它不能被改資料庫的連結導致無法登入。」
 *
 * 不變量（整份檔案只為這一句存在）：
 *   **登入所需要的一切，不得存放在任何「會被安裝／遷移重新指向」的地方。**
 *
 * 為什麼家選在 CF Workers per-script Secrets（判斷過程留著，方便日後推翻）：
 *   - D1 / KV / R2 / Vectorize 全靠 **binding** 指過去，安裝器每次都會重新指一次
 *     ⇒ 換家＝換鎖。所以「搬到另一顆資料庫」根本不解問題。
 *   - Workers Secret **掛在 script 本身**，與 bindings 是兩套資源：
 *     `wrangler deploy` 帶新 bindings 重部不會洗掉它（journeys/gemini-key-lost-on-reinstall.md
 *     在 stage 完整重裝 24/24 顆 worker 後 secret 仍在；installer worker.js:1148 亦有同款實證）。
 *   - 它是**自足**的：讀出來就是完整的一份 JSON，裡面沒有任何「再去某顆 D1/KV 查一次」的指標。
 *     自足是重點——只要還要回頭查一次，就又被綁回去了。
 *   - 不開新 D1（P9：leo 2026-08-07「你建一顆新的 D1，以後就會偷偷溜去那裡建表」）。
 *   - 不牴觸 D38「KBDB 三張核心表永不加新的」：本檔是把東西**搬出去**，KBDB 表數不增不減。
 *
 * 容量（2026-08-10 查官方 developers.cloudflare.com/workers/platform/limits/，不是憑記憶）：
 *   - 每個變數（secret + text 合計）上限 **5 KB**
 *   - 每顆 worker 變數數量上限 **64（Free）/ 128（Paid）**，與 CRED_* 共用同一份額度
 *   ⇒ 故採「單一 store + 溢位分片」：`ARCRUN_AUTH_STORE`、`ARCRUN_AUTH_STORE_1`、`_2`…
 *      一份 ~4.5 KB 大約裝得下 12–15 個帳號；超過就自動長出下一片。
 *      這是刻意的取捨：**不**做「一個帳號一顆 secret」，因為那會用同一份 64 格的額度去跟
 *      workflow credential 搶位子，且沒有任何實例接近這個量級。
 *
 * 寫入路徑：CF Workers Scripts secrets 管理 API（唯寫，讀不回值）。
 *   與 routes/credentials.ts 走**同一支** putWorkerSecret/deleteWorkerSecret，不另造第二套
 *   （D36 教訓：AI 天生偏向新增一種做法而非沿用既有的，兩套並存必然漂移）。
 *
 * 讀取路徑：`env` 直接讀——**零網路呼叫**。這正是它比 KBDB 可靠的原因：
 *   登入不再依賴任何外部系統活著。
 *
 * ⚠️ 傳播延遲（誠實限制，mindset §7）：更新 secret 會產生 worker 的新版本，
 *   **既有 isolate 讀到的仍是舊 env**，要等新版本鋪開。故本檔帶一層 per-isolate 的
 *   write-through overlay（AUTH_OVERLAY_TTL_MS），讓「剛改完密碼立刻登入」在同一顆 isolate 上
 *   立即生效；跨 isolate 仍可能有數十秒的落差，這是平台特性，不假裝沒有。
 */
import type { Bindings } from '../types';
import { putWorkerSecret, deleteWorkerSecret } from '../routes/credentials';

/** 主分片名；溢位分片為 `${AUTH_STORE_PREFIX}_1`、`_2`… */
export const AUTH_STORE_PREFIX = 'ARCRUN_AUTH_STORE';
/** 單片安全上限（官方 5 KB，留 ~10% 給 JSON 結構與 UTF-8 膨脹）。 */
const SHARD_MAX_BYTES = 4600;
/** 剛寫完的資料在本 isolate 內優先採信多久（跨 isolate 傳播用）。 */
const AUTH_OVERLAY_TTL_MS = 180_000;
/**
 * 「剛寫完」加速器的 KV key 與存活時間。
 *
 * 🔴 為什麼需要它（2026-08-10 stage 演練**實測撞到**，不是預防性設計）：
 *   更新 secret 會產生 worker 新版本，**既有 isolate 讀到的還是舊 env**。實測「建好帳號 →
 *   立刻登入」有 **15 秒以上**登不進去，而且那幾次失敗**會被算進 5 次鎖定**
 *   ⇒ 安裝精靈「建立帳號 → 馬上登入」會把人鎖在門外 15 分鐘。**這正是本案要根治的病的變種。**
 *
 * 🔑 它**不是**認證的家，只是「新版本還沒鋪開時的臨時快遞」：
 *   - 讀取順序永遠是 **secret 優先**；secret 裡查不到／密碼對不上，才回頭問加速器一次
 *   - KV 被重裝指到新的空的 → 加速器空 → 退回 secret ⇒ **D61 的不變量不受影響**
 *   - 短 TTL：密碼雜湊不長期躺在 KV 裡（舊設計是永久躺著，這比舊的嚴格）
 */
const ACCEL_KEY = 'auth_store_recent';
const ACCEL_TTL_SECONDS = 600;
/** store 內 user id 前綴——呼叫端據此分辨「這筆住新家還是舊家（KBDB）」。 */
export const AUTH_ID_PREFIX = 'auth:';

export interface AuthUserRecord {
  id: string;
  email: string;
  display_name: string;
  status: string;
  role: string;
  libraries: string[];
  password_hash: string;
  created_at: string;
  updated_at: string;
}

/** console 管理員那一組（原本住 SESSIONS_KV `console:credentials`，重裝就跟著蒸發）。 */
export interface AuthConsoleRecord {
  email: string;
  salt: string;
  hash: string;
  created_at: string;
}

export interface AuthStoreData {
  version: number;
  console: AuthConsoleRecord | null;
  users: AuthUserRecord[];
}

interface ShardPayload {
  v: number;
  console?: AuthConsoleRecord | null;
  users?: AuthUserRecord[];
}

/** 寫入路徑未就緒（缺 CF_SECRETS_API_TOKEN / CF_ACCOUNT_ID，或 CF API 回錯）。 */
export class AuthStoreWriteError extends Error {}

// ── per-isolate overlay（見檔頭「傳播延遲」）─────────────────────────────────────
let overlay: AuthStoreData | null = null;
let overlayAt = 0;

function emptyStore(): AuthStoreData {
  return { version: 1, console: null, users: [] };
}

function shardNames(env: Bindings): string[] {
  const bag = env as unknown as Record<string, unknown>;
  return Object.keys(bag)
    .filter((k) => k === AUTH_STORE_PREFIX || /^ARCRUN_AUTH_STORE_\d+$/.test(k))
    .filter((k) => typeof bag[k] === 'string' && (bag[k] as string).length > 0)
    .sort((a, b) => shardIndex(a) - shardIndex(b));
}

function shardIndex(name: string): number {
  if (name === AUTH_STORE_PREFIX) return 0;
  return Number.parseInt(name.slice(AUTH_STORE_PREFIX.length + 1), 10) || 0;
}

function shardNameOf(index: number): string {
  return index === 0 ? AUTH_STORE_PREFIX : `${AUTH_STORE_PREFIX}_${index}`;
}

/** 這台實例的 env 裡有沒有認證儲存（不論裡面有沒有帳號）。 */
export function authStorePresent(env: Bindings): boolean {
  return shardNames(env).length > 0 || (overlay !== null && Date.now() - overlayAt < AUTH_OVERLAY_TTL_MS);
}

/**
 * 寫入路徑是否就緒——缺就誠實回報「不能改密碼」，不假綠。
 *
 * `tokenOverride`（2026-08-14，arcrun-rag#99）：`env.CF_SECRETS_API_TOKEN` 從沒被安裝器種過，
 * 這是每台新實例都會撞的硬斷點（不是 leo 個人的環境問題）。安裝精靈裝機當下手上有一把
 * 自己還有效的 OAuth token，讓 `/console/setup`／`/portal/admin/bootstrap` 把它隨請求帶入，
 * 這裡就把它算進「寫得進去嗎」的判斷——見 `routes/credentials.ts putWorkerSecret` 的完整說明。
 */
export function authStoreWritable(env: Bindings, tokenOverride?: string): boolean {
  return Boolean((tokenOverride || env.CF_SECRETS_API_TOKEN) && env.CF_ACCOUNT_ID);
}

/**
 * 讀出完整認證資料。**同步、零網路呼叫**——這就是分離的意義：
 * 登入不依賴 KBDB / D1 / KV 任何一個活著。
 * 壞掉的分片（JSON parse 失敗）誠實跳過，不讓一片損毀鎖死整台實例。
 */
export function readAuthStore(env: Bindings): AuthStoreData {
  if (overlay && Date.now() - overlayAt < AUTH_OVERLAY_TTL_MS) return overlay;
  return readAuthStoreFromEnv(env);
}

/**
 * 只讀 `env` 那一版（**跳過 overlay**）。
 *
 * 為什麼要分出這一支（#66 修補的一半）：read-modify-write 時，overlay 與 env 兩份都可能
 * 各自「有對方沒有的帳號」——overlay 可能來自加速器（別台 isolate 剛寫的），
 * env 可能是**比加速器更新**的一版（加速器過期、或這顆 isolate 已經吃到新版本）。
 * 只採信其中一份就會把另一份獨有的帳號寫掉，而 secret 是唯一真相源 ⇒ **永久消失**。
 */
function readAuthStoreFromEnv(env: Bindings): AuthStoreData {
  const bag = env as unknown as Record<string, unknown>;
  const out = emptyStore();
  for (const name of shardNames(env)) {
    let parsed: ShardPayload | null = null;
    try {
      parsed = JSON.parse(bag[name] as string) as ShardPayload;
    } catch {
      continue; // 損毀的分片跳過（其餘帳號仍登得進去）
    }
    if (!parsed || typeof parsed !== 'object') continue;
    if (parsed.console && !out.console) out.console = parsed.console;
    if (Array.isArray(parsed.users)) {
      for (const u of parsed.users) {
        if (u && typeof u.email === 'string' && typeof u.id === 'string') out.users.push(u);
      }
    }
  }
  return out;
}

/** 找一筆帳號（email 比對，大小寫不敏感）。 */
export function findAuthUserByEmail(env: Bindings, email: string): AuthUserRecord | null {
  const needle = email.trim().toLowerCase();
  return readAuthStore(env).users.find((u) => u.email.toLowerCase() === needle) ?? null;
}

export function findAuthUserById(env: Bindings, id: string): AuthUserRecord | null {
  return readAuthStore(env).users.find((u) => u.id === id) ?? null;
}

/** 判斷一個 record_id 是不是住新家（呼叫端據此決定打 store 還是打 KBDB）。 */
export function isAuthStoreId(recordId: string): boolean {
  return recordId.startsWith(AUTH_ID_PREFIX);
}

export function newAuthUserId(): string {
  const arr = new Uint8Array(12);
  crypto.getRandomValues(arr);
  return AUTH_ID_PREFIX + Array.from(arr).map((b) => b.toString(16).padStart(2, '0')).join('');
}

/**
 * 把整份認證資料切片後寫回 Workers Secrets。
 * 分片規則：console 一定放第 0 片；users 依序塞，塞不下就開下一片。
 * 多出來的舊分片會被刪掉（避免「刪了帳號卻還留在舊分片裡復活」）。
 */
export async function writeAuthStore(env: Bindings, data: AuthStoreData, tokenOverride?: string): Promise<void> {
  if (!authStoreWritable(env, tokenOverride)) {
    // arcrun-rag#99（2026-08-14，leo 傳實測畫面點破）：舊文案叫用戶「重新執行安裝／更新」，
    // 但在這個訊息會出現的所有情況下（包含安裝精靈本身，若它沒能把 install token 帶到）那句話
    // 都不保證成立——安裝／更新從來不會把 CF_SECRETS_API_TOKEN 種成一個常駐的值。
    // 對一個做不到的動作下指令＝把人導向死路（他會以為自己操作錯誤，反覆重試）。
    // 改成誠實描述現況＋給得出去的下一步（回報支援），不再承諾一個我們自己都不確定會生效的動作。
    // 只列 CF_SECRETS_API_TOKEN：CF_ACCOUNT_ID 已查證全新實例一定有
    //（installer/oauth-prototype/worker.js:1354 無條件注入每一顆部署的 worker），
    // 列兩項只會讓下一個人以為兩項都要查，白工一次。
    throw new AuthStoreWriteError(
      '這台實例目前寫不進認證儲存（缺可用的 Cloudflare 寫入憑證：CF_SECRETS_API_TOKEN）。' +
        '這是平台端的已知限制，不是你操作錯誤——目前沒有你自己在畫面上能做的下一步，' +
        '請把這則訊息完整截圖／複製給支援，並註明你剛才在做什麼（例如：安裝精靈裡建立第一個帳號、' +
        '事後新增使用者、或修改密碼），會需要人工協助排除。',
    );
  }

  const shards: string[] = [];
  let current: ShardPayload = { v: 1, console: data.console ?? null, users: [] };
  for (const u of data.users) {
    const trial: ShardPayload = { ...current, users: [...(current.users ?? []), u] };
    const size = new TextEncoder().encode(JSON.stringify(trial)).length;
    if (size > SHARD_MAX_BYTES && (current.users ?? []).length > 0) {
      shards.push(JSON.stringify(current));
      current = { v: 1, users: [u] };
    } else {
      current = trial;
    }
  }
  shards.push(JSON.stringify(current));

  // 單筆帳號本身就超過一片＝真的塞不下，誠實擋下（不靜默丟資料）
  for (const s of shards) {
    if (new TextEncoder().encode(s).length > 5000) {
      throw new AuthStoreWriteError('單筆認證資料超過 Cloudflare 變數 5 KB 上限，無法寫入。');
    }
  }

  const existing = shardNames(env);
  for (let i = 0; i < shards.length; i++) {
    await putWorkerSecret(env, shardNameOf(i), shards[i], tokenOverride);
  }
  for (const name of existing) {
    if (shardIndex(name) >= shards.length) await deleteWorkerSecret(env, name, tokenOverride);
  }

  overlay = { version: 1, console: data.console ?? null, users: [...data.users] };
  overlayAt = Date.now();

  // 加速器（非真相源，見 ACCEL_KEY 註解）：讓別的 isolate 在新版本鋪開前也讀得到剛寫的東西。
  // 寫失敗完全不影響正確性——最多就是回到「等 secret 傳播」的狀態，故吞掉例外。
  try {
    await env.SESSIONS_KV.put(
      ACCEL_KEY,
      JSON.stringify({ written_at: Date.now(), data: overlay }),
      { expirationTtl: ACCEL_TTL_SECONDS },
    );
  } catch {
    /* 加速器是加分項，不是必要條件 */
  }
}

/**
 * 「secret 裡查不到／密碼對不上」時再問一次加速器（見 ACCEL_KEY）。
 * 命中就把它放進本 isolate 的 overlay，呼叫端重跑一次同樣的查找即可。
 * 回傳是否真的拿到比較新的資料（沒有就不必重跑）。
 */
export async function hydrateFromAccelerator(env: Bindings): Promise<boolean> {
  let raw: string | null = null;
  try {
    raw = await env.SESSIONS_KV.get(ACCEL_KEY);
  } catch {
    return false;
  }
  if (!raw) return false;
  try {
    const parsed = JSON.parse(raw) as { written_at?: number; data?: AuthStoreData };
    if (!parsed?.data || !Array.isArray(parsed.data.users)) return false;
    if (overlay && overlayAt >= (parsed.written_at ?? 0)) return false; // 本地的更新
    overlay = { version: 1, console: parsed.data.console ?? null, users: parsed.data.users };
    overlayAt = parsed.written_at ?? Date.now();
    return true;
  } catch {
    return false;
  }
}

/**
 * 這台實例「剛剛才寫過認證儲存」嗎——亦即現在是不是**傳播空窗期**。
 *
 * 🔴 #66 用它分辨兩件長得一樣、後果完全相反的事：
 *   - 「查不到這個帳號」＝ 帳號真的被刪了 → 該擋（401）
 *   - 「查不到這個帳號」＝ secret 新版本還沒鋪到這顆 isolate → **不該擋，更不該刪 session**
 * 加速器的 key 只在寫入後存活 `ACCEL_TTL_SECONDS`，它存在就代表「最近有人動過認證儲存」。
 * 讀不到（KV 掛了／沒設）⇒ 回 false，退回舊行為，不會比現在更糟。
 */
export async function authStoreRecentlyWritten(env: Bindings): Promise<boolean> {
  try {
    return Boolean(await env.SESSIONS_KV.get(ACCEL_KEY));
  } catch {
    return false;
  }
}

/** 兩份 store 取聯集：同一個 id 以 `updated_at` 新者為準；只在一邊出現的一律保留。 */
function unionStores(a: AuthStoreData, b: AuthStoreData): AuthStoreData {
  const byId = new Map<string, AuthUserRecord>();
  for (const u of [...a.users, ...b.users]) {
    const prev = byId.get(u.id);
    if (!prev || (u.updated_at ?? '') >= (prev.updated_at ?? '')) byId.set(u.id, u);
  }
  return { version: 1, console: a.console ?? b.console ?? null, users: [...byId.values()] };
}

/**
 * 讀出來 → 改 → 寫回去（同一支，避免各處自己拼 read/modify/write）。
 *
 * 🔴 #66：**改之前先把手上這份補齊**。舊版直接 `readAuthStore(env)` 當底稿，而 `writeAuthStore`
 * 會把整份重切分片並刪掉多出來的舊分片 ⇒ 若底稿是「某個帳號被建立之前」的版本，
 * 那個帳號會在這次寫入中**被抹掉，且再也回不來**（secret 是唯一真相源，沒有第二份可還原）。
 * 這正是「改一次密碼＝有人被鎖在門外」的另一半病因。
 *
 * 補法：先問一次加速器，再把 env 版與 overlay 版**取聯集**當底稿——
 * 兩邊獨有的帳號都留下來；刪除仍然有效，因為 `fn()` 是在聯集**之後**才跑。
 */
export async function mutateAuthStore(
  env: Bindings,
  fn: (data: AuthStoreData) => void | Promise<void>,
  tokenOverride?: string,
): Promise<AuthStoreData> {
  await hydrateFromAccelerator(env);
  const next = unionStores(readAuthStore(env), readAuthStoreFromEnv(env));
  await fn(next);
  await writeAuthStore(env, next, tokenOverride);
  return next;
}

/** 診斷用（/health、/console/auth-status、daemon diagnostics 共用同一份判讀）。 */
export function authStoreStatus(env: Bindings): {
  present: boolean;
  writable: boolean;
  users: number;
  console_configured: boolean;
  shards: number;
} {
  const data = readAuthStore(env);
  return {
    present: authStorePresent(env),
    writable: authStoreWritable(env),
    users: data.users.length,
    console_configured: Boolean(data.console),
    shards: shardNames(env).length,
  };
}
