/**
 * RAG Portal 多人授權 — P2：用戶模型＋認證 API（portal-auth design §2/§4/§5，Gitea #24/#25）
 *
 * 架構（design D-1）：Portal＝cypher-executor 的 /portal 路由（非獨立 worker）。
 * 本檔只做 P2 的 auth/admin API；P3（/portal HTML 殼＋ /portal/data/* enforce）另一波。
 *
 * 鐵律對照：
 *   - rule 2.1/2.2：這是 UI session 登入（console-auth 同類先例），非 workflow credential
 *     原語。KDF 在 lib/portal-auth.ts（WebCrypto PBKDF2），本檔無解密/簽章/template 展開。
 *   - 零新表（design §2）：portal_user / portal_library 都是 KBDB 萬用表 template；
 *     資料經 KBDB base HTTP API 寫入（kbdbBase 慣例，不直連 D1、不寫 SQL）。
 *   - 子 namespace（design D-2）：一切帳號資料 owner_id＝`{CONSOLE_TENANT}::portal`。
 *     既有租戶查詢面（/kbdb/*、MCP）都以 CONSOLE_TENANT 過濾 → 物理上搜不到帳號資料
 *     （email/password_hash 不會出現在知識搜尋結果）。
 *   - session（design §4.3）：KV `portal_sess:{token}` 只存 record_id（TTL 暫存＝合規），
 *     **每個請求回讀 user record 當唯一真相源** → 停用/改權限即時生效，不靠 session 反向索引。
 *   - 絕不下發租戶字串（design §3.3 關鍵差異 vs console）：/portal/session 只回
 *     display_name/role/libraries。
 *   - 密碼永不明碼儲存、永不進 log（本檔不 log 任何 body）。
 */
import { Hono } from 'hono';
import type { Context } from 'hono';
import type { Bindings } from '../types';
import { kbdbBase } from './kbdb-proxy';
import { validateConsoleSession } from './console-auth';
import { hashPassword, verifyPassword, randomHex, generatePassword } from '../lib/portal-auth';
import { PORTAL_TEMPLATE_SEEDS } from '../lib/portal-seeds';

export const portalRouter = new Hono<{ Bindings: Bindings }>();

const SESSION_PREFIX = 'portal_sess:';
const LOCKFAIL_PREFIX = 'portal_lockfail:';
const LOCK_LIMIT = 5; // design §4.3：5 次失敗
const LOCK_TTL_SECONDS = 15 * 60; // 鎖 15 分鐘（KV TTL 自然過期）
const DEFAULT_SESSION_TTL = 604800; // 7 天（design §4.3，比 console 30 天緊）

const USER_TEMPLATE = 'portal_user';
const LIBRARY_TEMPLATE = 'portal_library';

// ── 基礎 helpers ────────────────────────────────────────────────────────────

/** 租戶字串（＝知識資料的 owner_id）。預設沿 console-auth 同款 'leo'。**只在 server 側使用，永不下發前端**。 */
export function portalTenant(env: Bindings): string {
  return env.CONSOLE_TENANT || 'leo';
}

/** 帳號子 namespace（design D-2）。 */
function portalNamespace(env: Bindings): string {
  return `${portalTenant(env)}::portal`;
}

function sessionTtl(env: Bindings): number {
  const n = Number.parseInt(env.PORTAL_SESSION_TTL ?? '', 10);
  // KV expirationTtl 下限 60 秒；壞值誠實退回預設而非炸掉
  return Number.isFinite(n) && n >= 60 ? n : DEFAULT_SESSION_TTL;
}

function bearerToken(c: Context<{ Bindings: Bindings }>): string | null {
  const auth = c.req.header('authorization') ?? '';
  return auth.match(/^Bearer\s+(\S+)/i)?.[1] ?? null;
}

/** KBDB 不可達/回錯時拋這個 → 各 route 統一 502 誠實回報（不假綠、不偽裝成 401）。 */
export class KbdbError extends Error {}

export async function kbdbFetch(env: Bindings, path: string, init?: RequestInit): Promise<Response> {
  const { base, headers } = kbdbBase(env);
  let res: Response;
  try {
    res = await fetch(`${base}${path}`, { ...init, headers: { ...headers, ...(init?.headers as Record<string, string> | undefined) } });
  } catch (e) {
    throw new KbdbError(`fetch ${path} 失敗：${e instanceof Error ? e.message : String(e)}`);
  }
  return res;
}

/** route handler 包一層：KbdbError → 502（誠實），其餘照拋。 */
export async function run(c: Context<{ Bindings: Bindings }>, fn: () => Promise<Response>): Promise<Response> {
  try {
    return await fn();
  } catch (e) {
    if (e instanceof KbdbError) return c.json({ error: `KBDB 不可達或回錯：${e.message}` }, 502);
    throw e;
  }
}

// ── KBDB 資料層 helpers（全走 base HTTP API，零 SQL）────────────────────────────

export interface PortalRecord {
  record_id: string;
  template_id: string;
  values: Record<string, string>;
}

/** 冪等確保 portal templates 存在（seed 是 API 行為，rule 07；/init/seed 與 bootstrap 共用）。 */
export async function ensurePortalTemplates(
  env: Bindings,
): Promise<{ created: string[]; existing: string[]; errors: string[] }> {
  const created: string[] = [];
  const existing: string[] = [];
  const errors: string[] = [];
  for (const seed of PORTAL_TEMPLATE_SEEDS) {
    try {
      const got = await kbdbFetch(env, `/templates/${encodeURIComponent(seed.name)}`);
      if (got.ok) {
        // 已存在 → 檢查 slots 是否落後 seed（如 P3 新增 portal_library.graph_source）。
        // updateRecord 對「不在 template slots_json 的 slot」會 reject——不補 slot，
        // 舊實例就永遠寫不進新標記。PATCH 補聯集（冪等，既有 record 不動）。
        const body = (await got.json().catch(() => null)) as {
          template?: { id: string; slots_json?: string };
        } | null;
        const tpl = body?.template;
        if (tpl?.id && tpl.slots_json) {
          let currentSlots: string[] = [];
          try {
            const parsed = JSON.parse(tpl.slots_json);
            if (Array.isArray(parsed)) currentSlots = parsed.filter((s): s is string => typeof s === 'string');
          } catch {
            /* slots_json 壞掉 → 視同空，補成 seed 全集 */
          }
          const missing = seed.slots.filter((s) => !currentSlots.includes(s));
          if (missing.length > 0) {
            const patched = await kbdbFetch(env, `/templates/${encodeURIComponent(tpl.id)}`, {
              method: 'PATCH',
              body: JSON.stringify({ slots: [...currentSlots, ...missing] }),
            });
            if (!patched.ok) throw new KbdbError(`PATCH /templates/${seed.name} 補 slots → ${patched.status}`);
          }
        }
        existing.push(seed.name);
        continue;
      }
      if (got.status !== 404) throw new KbdbError(`GET /templates/${seed.name} → ${got.status}`);
      const res = await kbdbFetch(env, '/templates', {
        method: 'POST',
        body: JSON.stringify({
          name: seed.name,
          slots: seed.slots,
          description: seed.description,
          created_by: seed.created_by,
        }),
      });
      if (!res.ok) throw new KbdbError(`POST /templates ${seed.name} → ${res.status}`);
      created.push(seed.name);
    } catch (e) {
      errors.push(`${seed.name}: ${e instanceof Error ? e.message : String(e)}`);
    }
  }
  return { created, existing, errors };
}

/** email → user record_id（design §2.3 head entry O(1) 查找：page_name=email 走 index）。 */
async function findUserRecordId(env: Bindings, email: string): Promise<string | null> {
  const ns = portalNamespace(env);
  const params = new URLSearchParams({
    page_name: email,
    entry_type: USER_TEMPLATE,
    owner_id: ns,
    limit: '1',
  });
  const res = await kbdbFetch(env, `/entries?${params.toString()}`);
  if (!res.ok) throw new KbdbError(`head entry 查找 → ${res.status}`);
  const body = (await res.json()) as { entries?: { content: string | null }[] };
  const content = body.entries?.[0]?.content;
  return content ?? null;
}

async function getRecordById(env: Bindings, recordId: string): Promise<PortalRecord | null> {
  const res = await kbdbFetch(env, `/records/${encodeURIComponent(recordId)}`);
  if (res.status === 404) return null;
  if (!res.ok) throw new KbdbError(`GET /records/${recordId} → ${res.status}`);
  const body = (await res.json()) as { record?: PortalRecord };
  return body.record ?? null;
}

async function patchRecordValues(env: Bindings, recordId: string, values: Record<string, string>): Promise<PortalRecord> {
  const res = await kbdbFetch(env, `/records/${encodeURIComponent(recordId)}`, {
    method: 'PATCH',
    body: JSON.stringify({ values }),
  });
  if (!res.ok) throw new KbdbError(`PATCH /records/${recordId} → ${res.status}`);
  const body = (await res.json()) as { record?: PortalRecord };
  if (!body.record) throw new KbdbError(`PATCH /records/${recordId} 回應缺 record`);
  return body.record;
}

export async function listRecordsByTemplate(env: Bindings, template: string): Promise<PortalRecord[]> {
  const ns = portalNamespace(env);
  const res = await kbdbFetch(env, `/records/by-template/${encodeURIComponent(template)}?owner_id=${encodeURIComponent(ns)}`);
  if (!res.ok) throw new KbdbError(`GET /records/by-template/${template} → ${res.status}`);
  const body = (await res.json()) as { records?: PortalRecord[] };
  return body.records ?? [];
}

interface CreateUserInput {
  email: string;
  display_name: string;
  role: 'user' | 'admin';
  libraries: string[];
  password_hash: string;
}

/** 建 portal_user record（子 namespace）＋ email head entry（§2.3）。 */
async function createPortalUser(env: Bindings, input: CreateUserInput): Promise<string> {
  const ns = portalNamespace(env);
  const now = new Date().toISOString();
  const res = await kbdbFetch(env, '/records', {
    method: 'POST',
    body: JSON.stringify({
      template: USER_TEMPLATE,
      owner_id: ns,
      values: {
        email: input.email,
        display_name: input.display_name,
        status: 'active',
        role: input.role,
        password_hash: input.password_hash,
        libraries: JSON.stringify(input.libraries),
        created_at: now,
        updated_at: now,
      },
    }),
  });
  if (!res.ok) throw new KbdbError(`POST /records（portal_user）→ ${res.status}`);
  const body = (await res.json()) as { record?: { record_id: string } };
  const recordId = body.record?.record_id;
  if (!recordId) throw new KbdbError('POST /records 回應缺 record_id');

  // head entry：page_name=email（indexed）→ content=record_id，O(1) 登入查找
  const head = await kbdbFetch(env, '/entries', {
    method: 'POST',
    body: JSON.stringify({
      entry_type: USER_TEMPLATE,
      page_name: input.email,
      content: recordId,
      owner_id: ns,
    }),
  });
  if (!head.ok) throw new KbdbError(`head entry 建立失敗（record ${recordId} 已建，需人工收拾）→ ${head.status}`);
  return recordId;
}

// ── user 值域 helpers ──────────────────────────────────────────────────────

export function parseLibraries(raw: string | undefined): string[] {
  if (!raw) return [];
  try {
    const arr = JSON.parse(raw);
    if (Array.isArray(arr) && arr.every((x) => typeof x === 'string')) return arr;
  } catch {
    /* fallthrough */
  }
  return [];
}

function isValidEmail(email: string): boolean {
  return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email) && email.length <= 254;
}

/** 庫名進 metadata/query（逗號分隔參數），故禁逗號/空白/怪字元。 */
function isValidLibraryName(name: string): boolean {
  return /^(\*|[A-Za-z0-9_-]{1,64})$/.test(name);
}

function validLibrariesInput(libs: unknown): libs is string[] {
  return Array.isArray(libs) && libs.length > 0 && libs.every((x) => typeof x === 'string' && isValidLibraryName(x));
}

/** admin 面向的公開 user 形狀：**絕不含 password_hash**。 */
function toPublicUser(rec: PortalRecord) {
  const v = rec.values;
  return {
    record_id: rec.record_id,
    email: v.email ?? '',
    display_name: v.display_name ?? '',
    status: v.status ?? '',
    role: v.role ?? '',
    libraries: parseLibraries(v.libraries),
    created_at: v.created_at ?? '',
    updated_at: v.updated_at ?? '',
  };
}

// ── session 閘 ────────────────────────────────────────────────────────────

export type AuthedUser = { token: string; recordId: string; values: Record<string, string> };
export type AuthResult = { ok: true; user: AuthedUser } | { ok: false; res: Response };

/**
 * portal session 閘：token → KV → record_id → **回讀 record**（唯一真相源）→ status=active。
 * 停用即時生效（design §4.3）；停用/孤兒 session 順手刪 KV（best-effort，正確性不依賴它）。
 */
export async function requirePortalUser(c: Context<{ Bindings: Bindings }>): Promise<AuthResult> {
  const token = bearerToken(c);
  if (!token) return { ok: false, res: c.json({ error: '未登入' }, 401) };
  const sess = await c.env.SESSIONS_KV.get(`${SESSION_PREFIX}${token}`);
  if (!sess) return { ok: false, res: c.json({ error: 'session 無效或已過期' }, 401) };
  let recordId: string | undefined;
  try {
    recordId = (JSON.parse(sess) as { record_id?: string }).record_id;
  } catch {
    /* fallthrough */
  }
  if (!recordId) {
    await c.env.SESSIONS_KV.delete(`${SESSION_PREFIX}${token}`);
    return { ok: false, res: c.json({ error: 'session 無效或已過期' }, 401) };
  }
  const rec = await getRecordById(c.env, recordId);
  if (!rec) {
    await c.env.SESSIONS_KV.delete(`${SESSION_PREFIX}${token}`);
    return { ok: false, res: c.json({ error: 'session 無效或已過期' }, 401) };
  }
  if ((rec.values.status ?? '') !== 'active') {
    await c.env.SESSIONS_KV.delete(`${SESSION_PREFIX}${token}`);
    return { ok: false, res: c.json({ error: '帳號已停用' }, 403) };
  }
  return { ok: true, user: { token, recordId, values: rec.values } };
}

async function requirePortalAdmin(c: Context<{ Bindings: Bindings }>): Promise<AuthResult> {
  const auth = await requirePortalUser(c);
  if (!auth.ok) return auth;
  if ((auth.user.values.role ?? '') !== 'admin') {
    return { ok: false, res: c.json({ error: '需要 admin 權限' }, 403) };
  }
  return auth;
}

// ── D-4 graph 粗閘 / D-8 工作流頁能力（P3；server 是唯一裁決點，前端只照 session 渲染）────

/**
 * 知識圖譜的「來源庫」集合（design D-4）：portal_library 中標 graph_source='true'
 * 且未停用的庫。**沒有任何庫標記時預設 ['general']**（D-4 定案）。
 */
export async function graphSourceLibraries(env: Bindings): Promise<string[]> {
  const libs = await listRecordsByTemplate(env, LIBRARY_TEMPLATE);
  const marked = libs
    .filter((l) => (l.values.graph_source ?? '') === 'true' && (l.values.status ?? 'active') !== 'disabled')
    .map((l) => l.values.name ?? '')
    .filter(Boolean);
  return marked.length > 0 ? marked : ['general'];
}

/** graph 粗閘判定：擁有任一 graph 來源庫的權限（或 ["*"] 全庫）才放行。 */
export async function hasGraphAccess(env: Bindings, userLibraries: string[]): Promise<boolean> {
  if (userLibraries.includes('*')) return true; // 全庫 → 必含來源庫，省一次 KBDB 呼叫
  if (userLibraries.length === 0) return false;
  const sources = await graphSourceLibraries(env);
  return sources.some((s) => userLibraries.includes(s));
}

/**
 * 工作流頁可見性（design D-8 定案：admin）：PORTAL_SHOW_WORKFLOWS = admin（預設）/ all / off。
 * 壞值誠實退回預設 admin（不因 typo 意外全開）。
 */
export function workflowsVisible(env: Bindings, role: string): boolean {
  const setting = (env.PORTAL_SHOW_WORKFLOWS ?? 'admin').toLowerCase();
  if (setting === 'off') return false;
  if (setting === 'all') return true;
  return role === 'admin';
}

/**
 * 上傳能力（portal-demo-suite）：PORTAL_UPLOAD_REPO / PORTAL_UPLOAD_GITEA / PORTAL_UPLOAD_TOKEN
 * 三個 bindings **齊全**才啟用。Mira 零影響：未設＝功能不存在（/portal/data/upload 404、
 * 前端 nav 隱藏）。這裡只是顯示提示——真閘在 /portal/data/upload 路由層，前端藏不藏都繞不過。
 */
export function uploadEnabled(env: Bindings): boolean {
  return Boolean(env.PORTAL_UPLOAD_REPO && env.PORTAL_UPLOAD_GITEA && env.PORTAL_UPLOAD_TOKEN);
}

/**
 * admin 操作目標 record 的成員資格驗證：record 的 email head entry（子 namespace 內）
 * 必須指回同一 record_id——同時證明「是 portal_user」且「在本實例的 {tenant}::portal 下」，
 * 防 admin 拿任意 record_id 改到不相干的 KBDB record。
 */
async function assertPortalUserRecord(env: Bindings, recordId: string): Promise<PortalRecord | null> {
  const rec = await getRecordById(env, recordId);
  if (!rec) return null;
  const email = rec.values.email;
  if (!email) return null;
  const headRecordId = await findUserRecordId(env, email);
  if (headRecordId !== recordId) return null;
  return rec;
}

// ── 登入節流（design §4.3：KV 計數，TTL 自然過期）──────────────────────────────

async function isLocked(env: Bindings, email: string): Promise<boolean> {
  const raw = await env.SESSIONS_KV.get(`${LOCKFAIL_PREFIX}${email}`);
  if (!raw) return false;
  try {
    return ((JSON.parse(raw) as { count?: number }).count ?? 0) >= LOCK_LIMIT;
  } catch {
    return false;
  }
}

async function recordLoginFail(env: Bindings, email: string): Promise<void> {
  const key = `${LOCKFAIL_PREFIX}${email}`;
  const raw = await env.SESSIONS_KV.get(key);
  let count = 0;
  if (raw) {
    try {
      count = (JSON.parse(raw) as { count?: number }).count ?? 0;
    } catch {
      count = 0;
    }
  }
  await env.SESSIONS_KV.put(key, JSON.stringify({ count: count + 1 }), { expirationTtl: LOCK_TTL_SECONDS });
}

async function clearLoginFail(env: Bindings, email: string): Promise<void> {
  await env.SESSIONS_KV.delete(`${LOCKFAIL_PREFIX}${email}`);
}

// ═══════════════════════════════ 認證端點 ═══════════════════════════════════

// POST /portal/login — body {email, password}。成功發 portal session token。
// 錯誤訊息刻意不分「帳號不存在 vs 密碼錯」（不洩帳號存在性）；停用帳號誠實回 403。
portalRouter.post('/portal/login', (c) =>
  run(c, async () => {
    const body = await c.req.json().catch(() => null);
    const email = String(body?.email ?? '').trim().toLowerCase();
    const password = String(body?.password ?? '');
    if (!email || !password) return c.json({ error: 'email 與 password 必填' }, 400);

    if (await isLocked(c.env, email)) {
      return c.json({ error: '登入失敗次數過多，已暫時鎖定，請 15 分鐘後再試' }, 429);
    }

    const recordId = await findUserRecordId(c.env, email);
    if (!recordId) {
      await recordLoginFail(c.env, email);
      return c.json({ error: 'email 或密碼錯誤' }, 401);
    }
    const rec = await getRecordById(c.env, recordId);
    if (!rec) {
      await recordLoginFail(c.env, email);
      return c.json({ error: 'email 或密碼錯誤' }, 401);
    }
    if ((rec.values.status ?? '') !== 'active') {
      return c.json({ error: '帳號已停用' }, 403);
    }
    const ok = await verifyPassword(password, rec.values.password_hash ?? '');
    if (!ok) {
      await recordLoginFail(c.env, email);
      return c.json({ error: 'email 或密碼錯誤' }, 401);
    }

    await clearLoginFail(c.env, email);
    const token = randomHex(32);
    // session 值只存 record_id（design §4.3）——權限/狀態每請求回讀 record，不快取進 session
    await c.env.SESSIONS_KV.put(`${SESSION_PREFIX}${token}`, JSON.stringify({ record_id: recordId }), {
      expirationTtl: sessionTtl(c.env),
    });
    return c.json({
      success: true,
      session_token: token,
      display_name: rec.values.display_name ?? '',
      role: rec.values.role ?? 'user',
      libraries: parseLibraries(rec.values.libraries),
      // 絕不回租戶字串（design §3.3：portal_user 拿到租戶字串就能繞過庫 filter 直打 /kbdb/*）
    });
  }),
);

// POST /portal/logout
portalRouter.post('/portal/logout', async (c) => {
  const token = bearerToken(c);
  if (token) await c.env.SESSIONS_KV.delete(`${SESSION_PREFIX}${token}`);
  return c.json({ success: true });
});

// GET /portal/session — 每請求回讀 user record（真相源）；回 display_name/role/libraries，
// **絕不回租戶字串**（design §5）。
// P3 補能力欄位（前端據此渲染，design §6/D-4/D-8）：graph_allowed（graph 模式要不要顯示）、
// workflows_visible（工作流頁要不要顯示）。**這兩個只是顯示提示——真正的擋在
// /portal/data/* 路由層**（無權 403/404），前端藏不藏都繞不過。
portalRouter.get('/portal/session', (c) =>
  run(c, async () => {
    const auth = await requirePortalUser(c);
    if (!auth.ok) return auth.res;
    const v = auth.user.values;
    const role = v.role ?? 'user';
    const libraries = parseLibraries(v.libraries);
    return c.json({
      valid: true,
      display_name: v.display_name ?? '',
      role,
      libraries,
      graph_allowed: await hasGraphAccess(c.env, libraries),
      workflows_visible: workflowsVisible(c.env, role),
      // portal-demo-suite：上傳頁能力（bindings 齊全才 true；同上，只是顯示提示，真閘在路由層）
      upload_enabled: uploadEnabled(c.env),
    });
  }),
);

// POST /portal/me/password — body {current, new}。驗舊密改新密。
portalRouter.post('/portal/me/password', (c) =>
  run(c, async () => {
    const auth = await requirePortalUser(c);
    if (!auth.ok) return auth.res;
    const body = await c.req.json().catch(() => null);
    const current = String(body?.current ?? '');
    const next = String(body?.new ?? '');
    if (!current || !next) return c.json({ error: 'current 與 new 必填' }, 400);
    if (next.length < 8) return c.json({ error: '新密碼至少 8 碼' }, 400);

    const ok = await verifyPassword(current, auth.user.values.password_hash ?? '');
    if (!ok) return c.json({ error: '舊密碼不正確' }, 401);

    const newHash = await hashPassword(next);
    await patchRecordValues(c.env, auth.user.recordId, {
      password_hash: newHash,
      updated_at: new Date().toISOString(),
    });
    return c.json({ success: true });
  }),
);

// ═══════════════════════════════ admin 端點 ══════════════════════════════════

// POST /portal/admin/bootstrap — 需 **console owner session**（design D-7：owner secret 是
// 安裝期人閘，不引入新 secret、不開放無閘註冊）。建第一個 role=admin 的 portal_user；
// 已有 admin → 409 拒絕重複 bootstrap。順手冪等確保 templates（seed 是 API 行為）。
portalRouter.post('/portal/admin/bootstrap', (c) =>
  run(c, async () => {
    const consoleOk = await validateConsoleSession(c.env, c.req.header('authorization'));
    if (!consoleOk) return c.json({ error: '需要 console owner session（先登入 /console）' }, 401);

    const seeded = await ensurePortalTemplates(c.env);
    if (seeded.errors.length > 0) {
      return c.json({ error: `portal templates seed 失敗：${seeded.errors.join('; ')}` }, 502);
    }

    const users = await listRecordsByTemplate(c.env, USER_TEMPLATE);
    if (users.some((u) => (u.values.role ?? '') === 'admin')) {
      return c.json({ error: '已有 admin，bootstrap 只能執行一次；後續帳號請用 /portal/admin/users' }, 409);
    }

    const body = await c.req.json().catch(() => null);
    const email = String(body?.email ?? '').trim().toLowerCase();
    const password = String(body?.password ?? '');
    const displayName = String(body?.display_name ?? '').trim() || email;
    if (!isValidEmail(email)) return c.json({ error: 'email 格式不正確' }, 400);
    if (password.length < 8) return c.json({ error: '密碼至少 8 碼' }, 400);
    if (await findUserRecordId(c.env, email)) return c.json({ error: '此 email 已存在' }, 409);

    const recordId = await createPortalUser(c.env, {
      email,
      display_name: displayName,
      role: 'admin',
      libraries: ['*'], // bootstrap admin 預設全庫（design §3.3：["*"]＝不注 library filter）
      password_hash: await hashPassword(password),
    });
    return c.json({ success: true, record_id: recordId, email, role: 'admin' });
  }),
);

// GET /portal/admin/users — 同仁列表（role=admin 閘）。**回應剝除 password_hash**。
portalRouter.get('/portal/admin/users', (c) =>
  run(c, async () => {
    const auth = await requirePortalAdmin(c);
    if (!auth.ok) return auth.res;
    const users = await listRecordsByTemplate(c.env, USER_TEMPLATE);
    return c.json({ success: true, users: users.map(toPublicUser), count: users.length });
  }),
);

// POST /portal/admin/users — 新增同仁。body {email, display_name?, role?, libraries?, password?}。
// 未帶 password → server 產一次性密碼隨回應回傳一次（不落地明碼，design §4.3 簡化版）。
portalRouter.post('/portal/admin/users', (c) =>
  run(c, async () => {
    const auth = await requirePortalAdmin(c);
    if (!auth.ok) return auth.res;
    const body = await c.req.json().catch(() => null);
    const email = String(body?.email ?? '').trim().toLowerCase();
    const displayName = String(body?.display_name ?? '').trim() || email;
    const role = body?.role === 'admin' ? 'admin' : 'user';
    const libraries: string[] = validLibrariesInput(body?.libraries) ? (body.libraries as string[]) : ['general'];
    if (!isValidEmail(email)) return c.json({ error: 'email 格式不正確' }, 400);
    if (body?.libraries !== undefined && !validLibrariesInput(body?.libraries)) {
      return c.json({ error: 'libraries 須為非空字串陣列（庫名限 A-Za-z0-9_- 或 "*"）' }, 400);
    }
    if (await findUserRecordId(c.env, email)) return c.json({ error: '此 email 已存在' }, 409);

    let password = body?.password !== undefined ? String(body.password) : '';
    let generated: string | undefined;
    if (password) {
      if (password.length < 8) return c.json({ error: '密碼至少 8 碼' }, 400);
    } else {
      generated = generatePassword();
      password = generated;
    }

    const recordId = await createPortalUser(c.env, {
      email,
      display_name: displayName,
      role,
      libraries,
      password_hash: await hashPassword(password),
    });
    const rec = await getRecordById(c.env, recordId);
    return c.json({
      success: true,
      user: rec ? toPublicUser(rec) : { record_id: recordId, email },
      // 一次性回傳（不儲存明碼）；admin 口頭轉交同仁後即失效於 server 側
      ...(generated ? { generated_password: generated } : {}),
    });
  }),
);

// PATCH /portal/admin/users/:id — 改 status/role/libraries（design §5）。
// 停用即時生效機制＝每請求回讀 record（§4.3），不依賴刪 session。
portalRouter.patch('/portal/admin/users/:id', (c) =>
  run(c, async () => {
    const auth = await requirePortalAdmin(c);
    if (!auth.ok) return auth.res;
    const recordId = c.req.param('id');
    const rec = await assertPortalUserRecord(c.env, recordId);
    if (!rec) return c.json({ error: '用戶不存在' }, 404);

    const body = await c.req.json().catch(() => null);
    if (!body) return c.json({ error: 'body 必須是 JSON' }, 400);
    const patch: Record<string, string> = {};
    if (body.status !== undefined) {
      if (body.status !== 'active' && body.status !== 'disabled') {
        return c.json({ error: 'status 只能是 active / disabled' }, 400);
      }
      patch.status = body.status;
    }
    if (body.role !== undefined) {
      if (body.role !== 'user' && body.role !== 'admin') return c.json({ error: 'role 只能是 user / admin' }, 400);
      patch.role = body.role;
    }
    if (body.libraries !== undefined) {
      if (!validLibrariesInput(body.libraries)) {
        return c.json({ error: 'libraries 須為非空字串陣列（庫名限 A-Za-z0-9_- 或 "*"）' }, 400);
      }
      patch.libraries = JSON.stringify(body.libraries);
    }
    if (Object.keys(patch).length === 0) return c.json({ error: '沒有可更新的欄位（status/role/libraries）' }, 400);

    // 鎖死保護（P4，總管派工明定）：**不可停用/降級最後一個 active admin**——
    // 否則系統再無人能管帳號（bootstrap 只能跑一次，409），變成鎖死狀態。
    // 只在「目標現在是 active admin 且 patch 會使它不再是」時才多打一次 list（平時零成本）。
    const isActiveAdmin = (rec.values.role ?? '') === 'admin' && (rec.values.status ?? '') === 'active';
    const wouldLoseAdmin = patch.status === 'disabled' || patch.role === 'user';
    if (isActiveAdmin && wouldLoseAdmin) {
      const all = await listRecordsByTemplate(c.env, USER_TEMPLATE);
      const otherActiveAdmins = all.filter(
        (u) => u.record_id !== recordId && (u.values.role ?? '') === 'admin' && (u.values.status ?? '') === 'active',
      );
      if (otherActiveAdmins.length === 0) {
        return c.json({ error: '不可停用或降級最後一個管理員——系統至少要保留一個 active admin' }, 409);
      }
    }
    patch.updated_at = new Date().toISOString();

    const updated = await patchRecordValues(c.env, recordId, patch);
    return c.json({ success: true, user: toPublicUser(updated) });
  }),
);

// POST /portal/admin/users/:id/reset-password — 產一次性新密碼回傳（design §4.3 簡化版：
// admin 口頭轉交；must_change 首登改密列第二波）。
portalRouter.post('/portal/admin/users/:id/reset-password', (c) =>
  run(c, async () => {
    const auth = await requirePortalAdmin(c);
    if (!auth.ok) return auth.res;
    const recordId = c.req.param('id');
    const rec = await assertPortalUserRecord(c.env, recordId);
    if (!rec) return c.json({ error: '用戶不存在' }, 404);

    const password = generatePassword();
    await patchRecordValues(c.env, recordId, {
      password_hash: await hashPassword(password),
      updated_at: new Date().toISOString(),
    });
    return c.json({ success: true, password }); // 一次性回傳，server 不留明碼
  }),
);

// ── 庫目錄（design §3.2 portal_library：登記簿；庫本體＝條目上的 metadata 標記）────

function toPublicLibrary(rec: PortalRecord) {
  const v = rec.values;
  return {
    record_id: rec.record_id,
    name: v.name ?? '',
    display_name: v.display_name ?? '',
    description: v.description ?? '',
    status: v.status ?? '',
    // D-4：此庫是否為知識圖譜萃取來源（graph 粗閘按這個判定；全都沒標 → 預設 general）
    graph_source: (v.graph_source ?? '') === 'true',
  };
}

// GET /portal/admin/libraries — 庫目錄列表。
portalRouter.get('/portal/admin/libraries', (c) =>
  run(c, async () => {
    const auth = await requirePortalAdmin(c);
    if (!auth.ok) return auth.res;
    const libs = await listRecordsByTemplate(c.env, LIBRARY_TEMPLATE);
    return c.json({ success: true, libraries: libs.map(toPublicLibrary), count: libs.length });
  }),
);

// POST /portal/admin/libraries — 登記一個庫。body {name, display_name?, description?}。
portalRouter.post('/portal/admin/libraries', (c) =>
  run(c, async () => {
    const auth = await requirePortalAdmin(c);
    if (!auth.ok) return auth.res;
    const body = await c.req.json().catch(() => null);
    const name = String(body?.name ?? '').trim();
    if (!isValidLibraryName(name) || name === '*') {
      return c.json({ error: '庫名限 A-Za-z0-9_-（1-64 字元；"*" 是保留值不可登記）' }, 400);
    }
    const seeded = await ensurePortalTemplates(c.env);
    if (seeded.errors.length > 0) {
      return c.json({ error: `portal templates seed 失敗：${seeded.errors.join('; ')}` }, 502);
    }
    const existing = await listRecordsByTemplate(c.env, LIBRARY_TEMPLATE);
    if (existing.some((l) => (l.values.name ?? '') === name)) {
      return c.json({ error: `庫 ${name} 已登記` }, 409);
    }
    const ns = portalNamespace(c.env);
    const res = await kbdbFetch(c.env, '/records', {
      method: 'POST',
      body: JSON.stringify({
        template: LIBRARY_TEMPLATE,
        owner_id: ns,
        values: {
          name,
          display_name: String(body?.display_name ?? '').trim() || name,
          description: String(body?.description ?? '').trim(),
          status: 'active',
        },
      }),
    });
    if (!res.ok) throw new KbdbError(`POST /records（portal_library）→ ${res.status}`);
    const created = (await res.json()) as { record?: PortalRecord };
    return c.json({ success: true, library: created.record ? toPublicLibrary(created.record) : { name } });
  }),
);

// PATCH /portal/admin/libraries/:id — 改 display_name/description/status（停用庫＝翻 status slot）。
portalRouter.patch('/portal/admin/libraries/:id', (c) =>
  run(c, async () => {
    const auth = await requirePortalAdmin(c);
    if (!auth.ok) return auth.res;
    const recordId = c.req.param('id');
    // 成員資格：record 必須在本實例的庫目錄列表內（庫數小，list 比對即可）
    const libs = await listRecordsByTemplate(c.env, LIBRARY_TEMPLATE);
    if (!libs.some((l) => l.record_id === recordId)) return c.json({ error: '庫不存在' }, 404);

    const body = await c.req.json().catch(() => null);
    if (!body) return c.json({ error: 'body 必須是 JSON' }, 400);
    const patch: Record<string, string> = {};
    if (body.display_name !== undefined) patch.display_name = String(body.display_name).trim();
    if (body.description !== undefined) patch.description = String(body.description).trim();
    if (body.status !== undefined) {
      if (body.status !== 'active' && body.status !== 'disabled') {
        return c.json({ error: 'status 只能是 active / disabled' }, 400);
      }
      patch.status = body.status;
    }
    // D-4（P3）：標記/取消「知識圖譜來源庫」。boolean 進、slot 存 'true'/'false' 字串。
    if (body.graph_source !== undefined) {
      if (typeof body.graph_source !== 'boolean') {
        return c.json({ error: 'graph_source 只能是 true / false' }, 400);
      }
      patch.graph_source = body.graph_source ? 'true' : 'false';
    }
    if (Object.keys(patch).length === 0) {
      return c.json({ error: '沒有可更新的欄位（display_name/description/status/graph_source）' }, 400);
    }
    const updated = await patchRecordValues(c.env, recordId, patch);
    return c.json({ success: true, library: toPublicLibrary(updated) });
  }),
);
