/**
 * 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, sha256Hex } from '../lib/portal-auth';
import { PORTAL_TEMPLATE_SEEDS } from '../lib/portal-seeds';
// Arcrun#108：租戶字串只有一個產地（lib/tenant.ts）。帳號面用 accountTenant（普通 string），
// 知識資料面用 knowledgeOwner（TenantId）——型別分家，拿錯編不過。
import { accountTenant, knowledgeOwner, ownerField, ownerQuery, tenantFromApiKey, TenantUnresolvedError, type TenantId } from '../lib/tenant';
// arcrun-rag#10：/portal/admin/ai 存 Gemini key 走 credentials.ts 的**唯一**寫入路徑，
// 不在 portal 這層另造第二套儲存（D36：值進 Workers Secret，D1 只留 ref）。
import { storeCredential, hasCredential } from './credentials';
// D61 補充（2026-08-14，leo confirm「走C」，pending-changes.md「認證儲存要不要搬回 D1/KV」）：
// D61 把帳號搬去認證儲存（CF Workers Secrets）是為了躲開「重裝時 binding 被安裝器照名字
// 重新指到新建的空資源」這個病根——但 Workers Secrets 的**寫入**需要外部
// `CF_SECRETS_API_TOKEN`，這把 token 從安裝那天起就沒被種過，於是每一台全新實例永遠建不出
// 第一個帳號、也永遠加不了第二個（arcrun-rag#99）。
// 病根本身已經在 2026-08-13 被更早、更通用的 `shared/resource-rule`（Arcrun#97）解掉——
// 現在每次安裝/更新都會沿用既有 binding，不會再把 KBDB D1 重指到空資源。既然病根已解，
// 就不需要為了躲 binding 而去揹「需要外部 token」這筆新債：**帳號改回住 KBDB**
// （走 base HTTP API，D38 零 SQL，binding，永不需要外部 CF token）。
// 認證儲存（Workers Secrets）留著當「已經在跑 D61 的舊實例」的**讀路徑**——讀取零成本、
// 零外部憑證需求（只有寫入才要 token）——登入成功就順手搬進 KBDB（見 promoteToKbdb）。
import {
  AuthStoreWriteError,
  authStoreRecentlyWritten,
  findAuthUserByEmail,
  findAuthUserById,
  hydrateFromAccelerator,
  isAuthStoreId,
  mutateAuthStore,
  readAuthStore,
  type AuthUserRecord,
} from '../lib/portal-auth-store';

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';
export const LIBRARY_TEMPLATE = 'portal_library';

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

/**
 * 帳號層的租戶字串（**不是**知識資料的 owner_id，Arcrun#108 拆開）。
 *
 * 只用來組帳號子 namespace（`{tenant}::portal`，design D-2）與 cypher 自己寫的設定
 * （extractor_config / credentials 目錄）——那些都是 cypher 用同一個值寫進去的，所以自洽。
 *
 * 🔴 **不可以拿它過濾知識資料面**（三元組 / entries / records / 藏書地圖 / 工作流）：
 * 那批是 CLI／小幫手用實例 namespace 寫的，兩者對不上就是 #108
 * （leo 的 1854 條被 `CONSOLE_TENANT="leo"` 過濾成 0）。資料面請用
 * `lib/tenant.ts` 的 `knowledgeOwner(env)`——它回 `TenantId`，本函式回 `string`，
 * 型別上就分得開，不必靠人記得。
 *
 * **只在 server 側使用，永不下發前端**。
 */
export function portalTenant(env: Bindings): string {
  return accountTenant(env);
}

/** 帳號子 namespace（design D-2）。 */
function portalNamespace(env: Bindings): string {
  return `${accountTenant(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) {
    // 帳號還住在舊家（D61 認證儲存）時，寫入需要外部 CF_SECRETS_API_TOKEN——
    // 新家（KBDB）不需要，但尚未搬遷的既有帳號仍可能撞到這格，要看得出來是這件事
    // （不是 KBDB 的錯，也不是密碼的錯），且會在該帳號下次登入時自動搬進 KBDB 而解除。
    if (e instanceof AuthStoreWriteError) {
      return c.json({ error: `認證儲存寫入失敗：${e.message}`, code: 'auth_store_not_writable' }, 502);
    }
    // Arcrun#108：連「這台實例的知識放在哪一格」都解析不出來 → 誠實講「讀不到」，
    // 不拿 repo 預設值當答案然後回一頁空的（那正是本票的病：設定缺失被畫成「你沒有資料」）。
    if (e instanceof TenantUnresolvedError) {
      return c.json({ error: e.message, code: 'tenant_unresolved' }, 500);
    }
    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 };
}

// ── 認證儲存（D61 舊家）⇄ PortalRecord 轉換（呼叫端一律只認 PortalRecord，不必分辨住哪）──

function authUserToRecord(u: AuthUserRecord): PortalRecord {
  return {
    record_id: u.id,
    template_id: USER_TEMPLATE,
    values: {
      email: u.email,
      display_name: u.display_name,
      status: u.status,
      role: u.role,
      password_hash: u.password_hash,
      libraries: JSON.stringify(u.libraries ?? []),
      created_at: u.created_at,
      updated_at: u.updated_at,
    },
  };
}

function recordValuesToAuthUser(id: string, v: Record<string, string>): AuthUserRecord {
  return {
    id,
    email: (v.email ?? '').toLowerCase(),
    display_name: v.display_name ?? '',
    status: v.status ?? 'active',
    role: v.role ?? 'user',
    libraries: parseLibraries(v.libraries),
    password_hash: v.password_hash ?? '',
    created_at: v.created_at ?? new Date().toISOString(),
    updated_at: v.updated_at ?? new Date().toISOString(),
  };
}

/**
 * 舊實例自癒：在認證儲存（D61 舊家，CF Workers Secrets）找到的既有帳號，搬進 KBDB（新家）。
 * best-effort——搬不動（KBDB 不可達）不影響這次登入，只是下次還會再走一次舊路。
 * 沿用 #55「第一版不做跨版本遷移機制」的落地方式：**用一次成功的登入把自己搬過去**。
 * 回傳搬遷後的新 record_id；搬不動回 null（呼叫端沿用舊 record_id 繼續，讀路徑仍然通，
 * 只是這次的 session 仍會落在舊家，下次登入會再試一次）。
 */
async function promoteToKbdb(env: Bindings, rec: PortalRecord): Promise<string | null> {
  try {
    const email = (rec.values.email ?? '').toLowerCase();
    if (!email) return null;
    const already = await findKbdbUserRecordId(env, email);
    if (already) return already; // 更早一次登入已經搬過了，不重複建
    return await createKbdbUserRecord(env, email, {
      display_name: rec.values.display_name ?? '',
      status: rec.values.status ?? 'active',
      role: rec.values.role ?? 'user',
      password_hash: rec.values.password_hash ?? '',
      libraries: rec.values.libraries ?? '[]',
      created_at: rec.values.created_at ?? new Date().toISOString(),
      updated_at: rec.values.updated_at ?? new Date().toISOString(),
    });
  } catch {
    return null; // 搬遷失敗不擋登入（誠實：狀態可從 /health 的 auth_store 看出來）
  }
}

/** email → user record_id。**KBDB（新家）優先**；找不到才回退舊家（認證儲存）。 */
async function findUserRecordId(env: Bindings, email: string): Promise<string | null> {
  const inKbdb = await findKbdbUserRecordId(env, email);
  if (inKbdb) return inKbdb;
  return findAuthUserByEmail(env, email)?.id ?? null;
}

/** KBDB 的 email → record_id（design §2.3 head entry O(1) 查找）。 */
async function findKbdbUserRecordId(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> {
  // 舊家（D61 認證儲存）的帳號：零網路呼叫直接讀 env（換 D1／換租戶代號都影響不到，
  // 這正是它當初被選為「不可能因重裝而不見」的理由——見 lib/portal-auth-store.ts 檔頭）。
  if (isAuthStoreId(recordId)) {
    const u = findAuthUserById(env, recordId);
    return u ? authUserToRecord(u) : 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> {
  // 舊家（D61 認證儲存）的帳號仍改寫進 Workers Secrets（需要 CF_SECRETS_API_TOKEN；
  // 沒有 token 就誠實拋 AuthStoreWriteError → run() 轉 502。這批帳號會在下次登入時
  // 自動搬進 KBDB（見 promoteToKbdb），搬完之後就落進下面的 KBDB PATCH 分支。
  if (isAuthStoreId(recordId)) {
    let updated: AuthUserRecord | null = null;
    await mutateAuthStore(env, (data) => {
      const idx = data.users.findIndex((u) => u.id === recordId);
      if (idx < 0) throw new KbdbError(`認證儲存找不到帳號 ${recordId}`);
      const merged = { ...authUserToRecord(data.users[idx]).values, ...values };
      updated = recordValuesToAuthUser(recordId, merged);
      data.users[idx] = updated;
    });
    if (!updated) throw new KbdbError(`認證儲存更新失敗 ${recordId}`);
    return authUserToRecord(updated);
  }
  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;
}

async function deleteKbdbRecord(env: Bindings, recordId: string): Promise<boolean> {
  if (isAuthStoreId(recordId)) {
    let found = false;
    await mutateAuthStore(env, (data) => {
      const idx = data.users.findIndex((u) => u.id === recordId);
      if (idx >= 0) {
        data.users.splice(idx, 1);
        found = true;
      }
    });
    return found;
  }
  const res = await kbdbFetch(env, `/records/${encodeURIComponent(recordId)}`, { method: 'DELETE' });
  if (res.status === 404) return false;
  if (!res.ok) throw new KbdbError(`DELETE /records/${recordId} → ${res.status}`);
  return true;
}

/** KV key for daemon's most-recently-reported active library names（t135 daemon hint）。 */
function daemonActiveKey(env: Bindings): string {
  return `${portalTenant(env)}:portal:daemon_active_libs`;
}

export async function listRecordsByTemplate(env: Bindings, template: string): Promise<PortalRecord[]> {
  // 帳號清單＝KBDB（新家）為主，認證儲存（舊家）尚未搬走的補在後面（同 email 以新家為準）。
  // 新家讀不到（KBDB 不可達）不算失敗——舊家仍是完整真相源之一，這裡只是把還沒搬完的人也列出來。
  if (template === USER_TEMPLATE) {
    let fromKbdb: PortalRecord[] = [];
    try {
      fromKbdb = await listKbdbRecordsByTemplate(env, template);
    } catch {
      fromKbdb = [];
    }
    const seen = new Set(fromKbdb.map((r) => (r.values.email ?? '').toLowerCase()));
    const fromLegacy = readAuthStore(env).users.map(authUserToRecord)
      .filter((r) => !seen.has((r.values.email ?? '').toLowerCase()));
    return [...fromKbdb, ...fromLegacy];
  }
  return listKbdbRecordsByTemplate(env, template);
}

/** KBDB 原生的 by-template 查詢（portal_library 等「資料」也走這條，那些本來就只住知識庫）。 */
async function listKbdbRecordsByTemplate(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（design §2.3）——低階寫入，
 * `createPortalUser`（一般建帳號）與 `promoteToKbdb`（舊帳號搬遷）共用同一條寫入路徑。
 */
async function createKbdbUserRecord(env: Bindings, email: string, values: Record<string, string>): Promise<string> {
  const ns = portalNamespace(env);
  const res = await kbdbFetch(env, '/records', {
    method: 'POST',
    body: JSON.stringify({ template: USER_TEMPLATE, owner_id: ns, values: { ...values, email } }),
  });
  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: email, content: recordId, owner_id: ns }),
  });
  if (!head.ok) throw new KbdbError(`head entry 建立失敗（record ${recordId} 已建，需人工收拾）→ ${head.status}`);
  return recordId;
}

/**
 * 建帳號。**2026-08-14 起改回一律建在 KBDB**（binding，走 base HTTP API，D38 零 SQL），
 * 不再需要外部 `CF_SECRETS_API_TOKEN`——這正是本次補的結構性缺口（見檔頭「D61 補充」）。
 */
async function createPortalUser(env: Bindings, input: CreateUserInput): Promise<string> {
  const now = new Date().toISOString();
  return createKbdbUserRecord(env, input.email.toLowerCase(), {
    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,
  });
}

// ── 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，正確性不依賴它）。
 *
 * 🔴 `Leo/arcrun-rag#66`（2026-08-10，leo 本人被鎖在 stage 外面的那條）：
 *   **「這一瞬間讀不到」不可以觸發不可逆的動作。**
 *   認證的家是 CF Workers Secret，改它（改密碼／建帳號／停用）會產生 worker 新版本，
 *   **既有 isolate 讀到的還是舊 env**（`#55` 實測 ≥15 秒）。舊版在那個空窗裡：
 *     ① `getRecordById` 讀不到 → ② **直接把 session 從 KV 刪掉** → ③ 回 401
 *   ⇒ 帶著一個**完全有效的 token**，登入狀態被當場銷毀，等 secret 鋪開也回不來。
 *   `#55` 補的「讀不到就再問一次加速器」只加在登入路徑（`findAndVerifyUser`），這道門沒有。
 *
 *   三段修法（缺一不可）：
 *   1. **先問一次加速器再判定**——與登入路徑同一招，同一支 `hydrateFromAccelerator`。
 *   2. **永不因「讀不到」刪 session**。刪是 best-effort 清潔工，而它清掉的是使用者唯一的
 *      憑據；KV 的 TTL 本來就會回收，這件事沒有非做不可的理由。
 *   3. 仍然讀不到且**正在傳播空窗**（加速器 key 還在）→ 回 **503 `auth_store_propagating`**，
 *      不是 401。理由在前端：portal 的 `guard401()` 一看到 401 就清 localStorage 踢回登入頁
 *      ⇒ 就算 KV 那筆還在，使用者手上的 token 也被自己的瀏覽器丟掉了。
 *      **後端不刪、前端不丟，這件事才算真的修好。**
 */
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) {
    // 這一筆 session 的內容本身壞掉＝確定的事實（不是讀不到），刪它是對的。
    await c.env.SESSIONS_KV.delete(`${SESSION_PREFIX}${token}`);
    return { ok: false, res: c.json({ error: 'session 無效或已過期' }, 401) };
  }
  let rec = await getRecordById(c.env, recordId);
  if (!rec && (await hydrateFromAccelerator(c.env))) {
    rec = await getRecordById(c.env, recordId); // ①：與登入路徑同一招，再問一次加速器
  }
  if (!rec) {
    // ③：分辨「傳播空窗」與「帳號真的沒了」——前者不可以把人踢出去。
    if (await authStoreRecentlyWritten(c.env)) {
      return {
        ok: false,
        res: c.json(
          {
            error: '認證資料正在更新中（Cloudflare 正在鋪開新版本），請稍候幾秒再試——你並沒有被登出。',
            code: 'auth_store_propagating',
          },
          503,
        ),
      };
    }
    // ②：查無此帳號（可能真的被刪了）→ 擋下即可，**不刪 session**（KV TTL 自己會回收）。
    return { ok: false, res: c.json({ error: 'session 無效或已過期' }, 401) };
  }
  if ((rec.values.status ?? '') !== 'active') {
    // 讀得到 record ＝ 確定的事實，停用要即時生效，刪 session 是對的。
    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}`);
}

/**
 * 這台實例是不是「一個帳號都沒有」（KBDB 新家空、認證儲存舊家也空/讀不到）。
 * 只在「查無此帳號」時才呼叫，不進正常登入熱路徑（#10「寧可明顯失敗，不要靜默錯置」）。
 */
async function instanceHasNoAuthData(env: Bindings): Promise<boolean> {
  if (readAuthStore(env).users.length > 0) return false; // 舊家還有尚未搬遷的帳號
  try {
    return (await listKbdbRecordsByTemplate(env, USER_TEMPLATE)).length === 0;
  } catch {
    return true; // KBDB 讀不到 ＋ 舊家空 ＝ 這台實例確實沒有可用的登入資料
  }
}

/**
 * D61：查帳號＋驗密碼的**唯一**入口（portal 登入與兩支 daemon 端點共用，避免三份走樣）。
 *
 * 🔴 為什麼要「失敗後再問一次加速器」（2026-08-10 stage 演練實測撞到的坑）：
 *   認證的家是 CF Workers Secret，改它會產生 worker 新版本，**既有 isolate 讀到的還是舊 env**。
 *   實測「建好帳號 → 立刻登入」有 15 秒以上是 401，而且那幾次還被算進 5 次鎖定
 *   ⇒ 安裝精靈「建立帳號 → 馬上登入」會把人鎖在門外 15 分鐘。
 *   ⇒ 所以查不到／密碼對不上時，**先問一次加速器再判定失敗**（見 lib 的 ACCEL_KEY 註解）。
 *   ⇒ 加速器讀不到也沒關係，只是回到「等傳播」；它不是真相源，換 KV 不影響 D61 的不變量。
 */
async function findAndVerifyUser(
  env: Bindings,
  email: string,
  password: string,
): Promise<{ recordId: string | null; rec: PortalRecord | null; ok: boolean }> {
  const attempt = async () => {
    const recordId = await findUserRecordId(env, email);
    const rec = recordId ? await getRecordById(env, recordId) : null;
    const ok = rec ? await verifyPassword(password, rec.values.password_hash ?? '') : false;
    return { recordId, rec, ok };
  };
  const first = await attempt();
  if (first.ok) return first;
  if (await hydrateFromAccelerator(env)) {
    const second = await attempt();
    if (second.ok || second.rec) return second;
  }
  return first;
}

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

// 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, rec, ok } = await findAndVerifyUser(c.env, email, password);
    if (!recordId || !rec) {
      // 明顯失敗（arcrun-rag#10「寧可明顯失敗，不要靜默錯置」套到門鎖上）：
      // 「這台實例一個帳號都沒有」跟「你密碼打錯」是兩件事，不准混成同一句話——
      // 2026-08-09 leo 就是被這個誤判鎖了 15 分鐘，而他的密碼從頭到尾都是對的。
      // ⇒ 回一個**分得出來**的錯，而且**不計入鎖定**。
      if (await instanceHasNoAuthData(c.env)) {
        return c.json(
          {
            error:
              '這台實例讀不到任何登入資料——不是密碼錯。認證儲存是空的，' +
              '請重新執行安裝／更新以重新建立管理員帳號。',
            code: 'auth_store_empty',
            auth_store: { home: 'kbdb', writable: true, users: 0 },
          },
          503,
        );
      }
      await recordLoginFail(c.env, email);
      return c.json({ error: 'email 或密碼錯誤' }, 401);
    }
    if ((rec.values.status ?? '') !== 'active') {
      return c.json({ error: '帳號已停用' }, 403);
    }
    if (!ok) {
      await recordLoginFail(c.env, email);
      return c.json({ error: 'email 或密碼錯誤' }, 401);
    }

    // 自癒：這次是拿舊家（D61 認證儲存）的帳號登進來的 → 順手搬進 KBDB（新家），
    // 且**這次登入發出的 session 就直接指向新 record_id**（搬遷成功的話）——不必等
    // 下一次登入，密碼變更／admin 編輯這類寫入這次就已經走得到不需要外部 token 的 KBDB 路。
    // 搬不動（KBDB 不可達）就沿用舊 record_id，讀路徑仍然通，只是寫入仍會走舊家那格。
    let sessionRecordId = recordId;
    if (isAuthStoreId(recordId)) {
      const migrated = await promoteToKbdb(c.env, rec);
      if (migrated) sessionRecordId = migrated;
    }

    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: sessionRecordId }), {
      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),
      // session 還能活多久（秒）。**非機密**（是這台實例的 TTL 設定，不是任何人的憑據），
      // 但呼叫端需要它才能把自己發的憑證對齊這個上限——arcrun-mcp 用它把 OAuth
      // access_token 的 TTL 夾到 min(自己的 TTL, 這個值)：否則 MCP token 活 30 天、
      // 底下的 portal session 7 天就死，使用者會在第 8 天遇到「連著卻查不到」的鬼打牆。
      session_expires_in: sessionTtl(c.env),
      // 絕不回租戶字串（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 ?? '',
      email: v.email ?? '', // t53：完成安裝清單在站內生 daemon config.json 要用（身分顯示欄）
      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),
    });
  }),
);

// ═════════════════ D62：改密碼與忘記密碼是**同一個機制** ═══════════════════════
//
// leo 2026-08-10 拍板（頂層 decisions-summary D62）：
//   「這兩個機制其實是一個機制，可以簡化。」
//   - 修改密碼：到「修改密碼」→ 輸入**現有的** → 輸入新的 → 覆蓋現有的
//   - 忘記密碼：寄給你**「修改密碼」連結** → **不輸入現有密碼（忽略）** → 輸入新的 → 覆蓋
//   ⇒ **同一個畫面、同一條寫入路徑，差別只有一格**：「現有密碼」是要填、還是被連結豁免。
//
// 🔴 **不做一次性密碼**（leo：「不要發一次性密碼太麻煩」）——這修正了 D50 的「一次性驗證碼」，
//    形態改成連結；D50 其餘部分（console 退場、不准沿用註冊辨識碼）不變。
// 🔴 連結的安全性（承 D50 的理由，不可退讓）：
//    ① **一次有效**——用掉就從 KV 刪除（本檔 consumeResetToken）
//    ② **會過期**——KV expirationTtl 30 分鐘，過了就是不存在
//    ③ **與註冊辨識碼不同源**——現場 crypto 亂數產生、只活在這台實例的 SESSIONS_KV，
//       跟 landing `SIGNUPS` 那組安裝辨識碼沒有任何關係。
//       D50 否決固定辨識碼的理由正是「綁定不會變 ⇒ 等於不會過期的鑰匙」。
// 🔴 已否決、不准寫回來的三條（D50）：console 密碼救援／重裝重設密碼／直接用固定辨識碼。
// 🔴 **入口在 portal，不是 console**（leo 2026-08-10：「是對 portal 不是對 console，
//    這樣 youlin 雖然忘記，我還是可以去 portal 忘記密碼。」）——console 與 portal 是
//    安裝時同一組帳密寫進兩個地方（D50 補刀），往 console 補洞不會多出任何一條路。

/** 「修改密碼」連結 token 的 KV key 前綴（存的是 token 的 sha256，不是 token 本身）。 */
const PWRESET_PREFIX = 'portal_pwreset:';
/** 連結有效期：30 分鐘（安全要求②「會過期」）。 */
const PWRESET_TTL_SECONDS = 30 * 60;
/** 同一個 email 的請求節流 key 前綴＋冷卻秒數（避免被拿來灌信）。 */
const PWRESET_THROTTLE_PREFIX = 'portal_pwreset_req:';
const PWRESET_THROTTLE_SECONDS = 120;
/** 代寄回呼票（讓郵差可以回頭問「這封真的是你要我寄的嗎」）的 key 前綴與存活秒數。 */
const RELAY_TICKET_PREFIX = 'portal_relay_ticket:';
const RELAY_TICKET_TTL_SECONDS = 120;

interface ResetTokenPayload {
  record_id: string;
  email: string;
  created_at: string;
}

/**
 * portal 前端（使用者會看到的那個網址）的 origin。
 * 與 index.ts 的 CORS 白名單**同一套推導**：UI_ORIGINS 優先，否則用 workers.dev 兄弟位址。
 * （2026-08-08 事故的教訓：能推導出來的東西就不要再多一個「必須被注入、漏了看不出來」的變數。）
 */
function portalUiOrigin(env: Bindings): string | null {
  const declared = String(env.UI_ORIGINS ?? '').split(',').map((s) => s.trim()).filter(Boolean);
  if (declared.length > 0) return declared[0];
  const sub = String(env.WORKER_SUBDOMAIN ?? '').trim();
  return sub ? `https://arcrun-rag-ui.${sub}.workers.dev` : null;
}

/** 發一張「修改密碼」連結票，回傳要放進連結的 token（明碼只在這一刻存在）。 */
async function issueResetToken(env: Bindings, recordId: string, email: string): Promise<string> {
  const token = randomHex(32);
  const payload: ResetTokenPayload = { record_id: recordId, email, created_at: new Date().toISOString() };
  await env.SESSIONS_KV.put(`${PWRESET_PREFIX}${await sha256Hex(token)}`, JSON.stringify(payload), {
    expirationTtl: PWRESET_TTL_SECONDS,
  });
  return token;
}

/** 看一眼票是否有效（**不消耗**）——給「點進連結先渲染畫面」用。 */
async function peekResetToken(env: Bindings, token: string): Promise<ResetTokenPayload | null> {
  if (!token || !/^[0-9a-f]{16,128}$/i.test(token)) return null;
  const raw = await env.SESSIONS_KV.get(`${PWRESET_PREFIX}${await sha256Hex(token)}`);
  if (!raw) return null;
  try {
    return JSON.parse(raw) as ResetTokenPayload;
  } catch {
    return null;
  }
}

/**
 * 用掉一張票（安全要求①「一次有效」）：**先刪再回傳**。
 * 順序是刻意的——先刪掉才回，兩個人同時點同一條連結時最多只有一個拿得到。
 */
async function consumeResetToken(env: Bindings, token: string): Promise<ResetTokenPayload | null> {
  const payload = await peekResetToken(env, token);
  if (!payload) return null;
  await env.SESSIONS_KV.delete(`${PWRESET_PREFIX}${await sha256Hex(token)}`);
  return payload;
}

/**
 * **唯一的密碼寫入路徑**（D62「同一條寫入路徑」的落地點）。
 * 修改密碼與忘記密碼都只能從這裡覆蓋密碼——不再有第二支自己 hash 自己 patch 的路。
 */
async function writeNewPassword(env: Bindings, recordId: string, newPassword: string): Promise<void> {
  const newHash = await hashPassword(newPassword);
  await patchRecordValues(env, recordId, {
    password_hash: newHash,
    updated_at: new Date().toISOString(),
  });
}

/**
 * 請中央 landing 代寄「修改密碼」連結。
 *
 * 🔴 **為什麼要代寄**（leo 2026-08-10 給的職責切法）：
 *   「其實這應該是 youlin 的實例告訴 arcrun.dev 說『我實例的重設密碼網址是 abc.recover，
 *     你幫我寄信給用戶讓他來修改密碼』，由 arcrun.dev 幫它寄出這封信。」
 *   ⇒ **實例**產生連結、管一次有效／過期／作廢；**arcrun.dev 只是郵差**，不碰任何認證邏輯。
 *   必須這樣切的硬理由：用戶自己的實例**根本沒有寄信能力**——安裝器部署 cypher 的 binding
 *   只有 ai/d1/kv_namespace/plain_text/secret_text/service/vectorize，**沒有 send_email**。
 * ⚠️ 「由我們中央代寄」是依 leo「寄給你」推導的**假設**，他尚未正式表態（D62 明列為未裁前置）。
 *
 * 🔴 **絕不把整條 URL 交給郵差**（總管 2026-08-10 紅線）：寄件網域 `arcrun.dev` 掛在 uncle6、
 *    帶 DKIM。郵差若肯收「任意 URL ＋ 任意 email」就寄，任何人裝一台實例就能用 `arcrun.dev`
 *    的名義、**通過 DKIM 驗證**把任意連結寄給任意人 ⇒ 一台開放的釣魚中繼，
 *    燒的是整個網域的信譽、波及所有用戶、**不可逆**。
 *
 *    做法：我們只交出**這台實例自己的 origin ＋ 一張回呼票**。連結由郵差自己組，而且郵差會
 *    **回頭打這個 origin** 問「這張票是你發的嗎」（見 /portal/password/relay-verify）。
 *    冒用別人的網域會被那台實例自己否認 ⇒ **主機屬於呼叫方這件事由郵差親自確認，
 *    不是相信呼叫方的宣稱**。
 *
 * `apiOrigin` 取自**這次請求真正抵達的位址**（`new URL(c.req.url).origin`），不新增任何
 * 需要被注入的變數——2026-08-08 兩次事故的教訓：能推導的就不要再多一個會被漏掉的設定。
 *
 * **不假綠**（mindset §7）：寄不出去就回 'failed' / 'not_configured'。
 */
async function relayResetLink(
  env: Bindings,
  apiOrigin: string,
  email: string,
  ticket: string,
): Promise<'sent' | 'not_configured' | 'failed'> {
  const base = String(env.PORTAL_MAIL_RELAY_BASE ?? '').trim().replace(/\/$/, '');
  if (!base) return 'not_configured';
  const headers: Record<string, string> = { 'Content-Type': 'application/json' };
  if (env.PORTAL_MAIL_RELAY_KEY) headers['X-Arcrun-Relay-Key'] = env.PORTAL_MAIL_RELAY_KEY;
  try {
    const res = await fetch(`${base}/api/send-password-reset`, {
      method: 'POST',
      headers,
      body: JSON.stringify({ email, api_origin: apiOrigin, ticket }),
    });
    return res.ok ? 'sent' : 'failed';
  } catch {
    return 'failed';
  }
}

/**
 * POST /portal/password/relay-verify — **郵差回頭確認用**（body `{ticket}`）。
 *
 * 這支存在的唯一理由是上面那條紅線：郵差不可以相信「呼叫方說這台是我的」。
 * 它會回頭打**連結裡的那個主機**問這張票在不在——
 *   - 真的是這台發的 → 這裡答得出來 → 寄
 *   - 有人冒用別人的網域 → 被冒用的那台**根本沒有這張票** → 答不出來 → 郵差拒寄
 * 回應**不含 email 明碼、不含 reset token**，只回收件人 email 的 sha256 讓郵差核對它手上那個，
 * 以及要放進信裡的完整連結（由本台自己組，主機必然是自己）。
 * 票 TTL 兩分鐘、看過即焚——它不是憑據，只是一次握手。
 */
portalRouter.post('/portal/password/relay-verify', (c) =>
  run(c, async () => {
    const body = await c.req.json().catch(() => null);
    const ticket = String(body?.ticket ?? '').trim();
    if (!ticket || !/^[0-9a-f]{8,64}$/i.test(ticket)) return c.json({ ok: false }, 400);
    const key = `${RELAY_TICKET_PREFIX}${ticket}`;
    const raw = await c.env.SESSIONS_KV.get(key);
    if (!raw) return c.json({ ok: false }, 404);
    await c.env.SESSIONS_KV.delete(key); // 一次握手，看過即焚
    let parsed: { email: string; link: string };
    try {
      parsed = JSON.parse(raw) as { email: string; link: string };
    } catch {
      return c.json({ ok: false }, 404);
    }
    return c.json({ ok: true, email_sha256: await sha256Hex(parsed.email), link: parsed.link });
  }),
);

// GET /portal/password/reset-link?token=… — 信裡連結的落點。
// **連結的主機刻意是 cypher 自己**（＝郵差回呼確認的那個主機，兩者必須是同一個，
// 否則「郵差確認過的主機」與「信裡的主機」就不是同一件事，紅線等於沒守）。
// 這裡只做一件事：把人帶去 portal 前端的修改密碼畫面。
portalRouter.get('/portal/password/reset-link', (c) => {
  const token = c.req.query('token') ?? '';
  const ui = portalUiOrigin(c.env);
  if (!ui) return c.text('這台實例沒有設定 portal 前端網址，無法導向修改密碼畫面。', 500);
  return c.redirect(`${ui}/portal/#/reset?token=${encodeURIComponent(token)}`, 302);
});

// POST /portal/password/forgot — body {email}。**公開端點**（忘記密碼的人當然沒登入）。
//
// 不洩漏帳號存在性：帳號在不在，回的都是同一句話、同一個 200。
// 唯一會回錯的是「寄信功能根本沒接上」——那與「有沒有這個帳號」無關，講出來不洩漏任何事，
// 而不講就會讓人對著一封永遠不會到的信等下去（#49「把故障講成用戶的問題」的反面）。
//
// 🔴 arcrun-rag#38/#69/#25（2026-08-11 leo 親口點出兩個問題，改字前先讀）：
//   ① 「實例」是行話——目標用戶是「只會叫 AI 幫忙的人」，不懂什麼叫一台實例。
//   ② 「請管理員直接幫你改密碼」對單人使用者是死路——他自己就是管理員，等於叫他聯絡自己
//      （`#25` 同一種病：「我忘記 portal 密碼，畫面叫我去找管理員，但管理員就是我」）。
//   ⇒ 訊息改成白話（不提「實例」），而且給一條他自己走得完的路：**重新跑一次安裝／更新**
//   現在**真的有用**（不是安慰話）——arcrun-rag#38/#69/#25 同批修正讓安裝器學會「版本號一樣
//   不代表這個功能已經接上，沒接上就當作舊的重推」，所以照原本收到的安裝網址走一次，
//   選同一個 Cloudflare 帳號，這個功能就會自動接上，不需要自己改任何設定。
portalRouter.post('/portal/password/forgot', (c) =>
  run(c, async () => {
    const body = await c.req.json().catch(() => null);
    const email = String(body?.email ?? '').trim().toLowerCase();
    if (!email || !isValidEmail(email)) return c.json({ error: 'email 格式不正確' }, 400);

    if (!String(c.env.PORTAL_MAIL_RELAY_BASE ?? '').trim()) {
      return c.json(
        {
          error:
            '寄信功能還沒接上，所以「忘記密碼」的信寄不出去。' +
            '請照當初收到的安裝網址，重新執行一次安裝（選同一個 Cloudflare 帳號）——' +
            '完成後這個功能就會自動接上，不需要自己設定任何東西，也不用找任何人幫忙。',
          code: 'mail_relay_not_configured',
        },
        503,
      );
    }

    const generic = {
      success: true,
      message: '如果這個 email 有帳號，我們已經把「修改密碼」的連結寄過去了（連結 30 分鐘內有效、只能用一次）。',
    };

    // 節流：同一個 email 兩分鐘內只寄一次（擋灌信，也擋拿這支當帳號存在性探針的節奏）
    const throttleKey = `${PWRESET_THROTTLE_PREFIX}${email}`;
    if (await c.env.SESSIONS_KV.get(throttleKey)) return c.json(generic);
    await c.env.SESSIONS_KV.put(throttleKey, '1', { expirationTtl: PWRESET_THROTTLE_SECONDS });

    const recordId = await findUserRecordId(c.env, email).catch(() => null);
    if (!recordId) return c.json(generic); // 沒有這個帳號 → 一樣的回應，什麼都不寄

    const token = await issueResetToken(c.env, recordId, email);
    // 連結的主機＝這次請求真正抵達的位址（郵差待會兒就是回頭打這裡確認的）
    const apiOrigin = new URL(c.req.url).origin;
    const link = `${apiOrigin}/portal/password/reset-link?token=${encodeURIComponent(token)}`;
    const ticket = randomHex(16);
    await c.env.SESSIONS_KV.put(`${RELAY_TICKET_PREFIX}${ticket}`, JSON.stringify({ email, link }), {
      expirationTtl: RELAY_TICKET_TTL_SECONDS,
    });
    await relayResetLink(c.env, apiOrigin, email, ticket); // 寄不出去也回同一句（存在性不可由回應推得）
    return c.json(generic);
  }),
);

// GET /portal/password/reset?token=… — 點進連結時先問「這張票還有效嗎」（**不消耗**）。
// 回 email 讓畫面顯示「你正在為 xxx@yyy 設定新密碼」——票本身就證明持有人控制那個信箱。
portalRouter.get('/portal/password/reset', (c) =>
  run(c, async () => {
    const payload = await peekResetToken(c.env, c.req.query('token') ?? '');
    if (!payload) {
      return c.json(
        { valid: false, error: '這條連結已經失效了（只能用一次、30 分鐘內有效）。請回登入頁重新按一次「忘記密碼」。' },
        400,
      );
    }
    return c.json({ valid: true, email: payload.email });
  }),
);

/**
 * POST /portal/password/change — **D62 的那一支**：修改密碼與忘記密碼共用。
 *
 * body：`{ new, current? , reset_token? }`
 *   - 帶 `reset_token`（從信裡的連結來）→ **忽略 current**，票就是憑據
 *   - 沒帶 → 必須是登入狀態 ＋ 提供正確的 `current`
 * 兩條路在這一行之後**完全相同**（writeNewPassword）——這就是「差別只有一格」的實體。
 */
async function handlePasswordChange(c: Context<{ Bindings: Bindings }>): Promise<Response> {
  const body = await c.req.json().catch(() => null);
  const next = String(body?.new ?? '');
  const resetToken = String(body?.reset_token ?? '').trim();
  if (!next) return c.json({ error: 'new（新密碼）必填' }, 400);
  if (next.length < 8) return c.json({ error: '新密碼至少 8 碼' }, 400);

  // ── 忘記密碼那一格：憑連結，不問現有密碼 ──
  if (resetToken) {
    const payload = await consumeResetToken(c.env, resetToken);
    if (!payload) {
      return c.json(
        { error: '這條連結已經失效了（只能用一次、30 分鐘內有效）。請回登入頁重新按一次「忘記密碼」。', code: 'reset_token_invalid' },
        400,
      );
    }
    await writeNewPassword(c.env, payload.record_id, next);
    return c.json({ success: true, email: payload.email, via: 'reset_link' });
  }

  // ── 修改密碼那一格：要登入、要現有密碼 ──
  const auth = await requirePortalUser(c);
  if (!auth.ok) return auth.res;
  const current = String(body?.current ?? '');
  if (!current) return c.json({ error: 'current（現有密碼）必填' }, 400);
  const ok = await verifyPassword(current, auth.user.values.password_hash ?? '');
  if (!ok) return c.json({ error: '舊密碼不正確' }, 401);

  await writeNewPassword(c.env, auth.user.recordId, next);
  return c.json({ success: true, via: 'current_password' });
}

portalRouter.post('/portal/password/change', (c) => run(c, () => handlePasswordChange(c)));

// POST /portal/me/password — 舊名，**轉呼同一支**（現有前端與 CLI 還在用這個路徑）。
// 保留別名而不是留第二份實作：兩份必然漂移（D39／arcrun-rag#40「同一個事實兩份」）。
portalRouter.post('/portal/me/password', (c) => run(c, () => handlePasswordChange(c)));

// ═══════════════════════════════ 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);

    // 2026-08-14 起：createPortalUser 寫 KBDB（binding），不再需要安裝精靈遞任何臨時
    // CF token（arcrun-rag#99 那個結構性缺口已隨儲存層搬回 binding 一併解掉）。
    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' });
  }),
);

// POST /portal/admin/recover-password — 管理員自救援出口（arcrun-rag#25：唯一 admin 忘記
// portal 密碼就進不去，登入頁只會叫他「聯絡管理員」＝叫他聯絡自己，沒有下一步）。
//
// 根因：`/portal/admin/users/:id/reset-password`（上面）與其他 admin 端點全部要求
// `requirePortalAdmin`＝**要先有一個有效的 portal admin session**——雞生蛋問題：admin
// 密碼忘了就進不去 portal，進不去 portal 就沒有 session 去重設密碼。`bootstrap` 能繞過這關
// 是因為它吃的是**另一道獨立的閘**（console owner session，design D-7）；但 bootstrap
// 只能跑一次（已有 admin 就 409），事後沒有對應的「用同一道閘做救援」端點。
//
// 修法：開一個**只認 console owner session、不認 portal session**的救援端點，直接複用
// bootstrap 已經在用的 `validateConsoleSession`。console 帳密（`/console/setup` 首次設定時
// 建立）與 portal 帳密是完全分開存放的兩組（見 console-auth.ts），只要 console 密碼沒有一起忘記，
// 這條路就走得通——不必問人、不必讀原始碼，畫面上（/console → 設定 → Portal 帳號密碼救援）
// 就有完整入口。找不到該 email 的帳號 → 404（誠實，不誤導成別的錯誤）。
portalRouter.post('/portal/admin/recover-password', (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 body = await c.req.json().catch(() => null);
    const email = String(body?.email ?? '').trim().toLowerCase();
    if (!isValidEmail(email)) return c.json({ error: 'email 格式不正確' }, 400);

    const recordId = await findUserRecordId(c.env, email);
    const rec = recordId ? await getRecordById(c.env, recordId) : null;
    if (!recordId || !rec) return c.json({ error: `找不到 email＝${email} 的 portal 帳號` }, 404);

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

// 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',
  };
}

// POST /portal/daemon/libraries — body {email, password, libraries:[{name, display_name?}]}。
// t52（leo 2026-07-26：「用戶可以看到我有 2 個庫，地端雲端都是 2 個，如果只有一個一定被罵」）：
// 小幫手回報它看守的資料夾各自對應的庫，雲端**自動登記**——庫目錄與地端資料夾一比一。
// 認證＝同 /portal/daemon/config（用戶帳密）。已存在的庫略過（冪等），不覆寫顯示名。
// POST /portal/daemon/extract — 小幫手把「已轉成純文字的原稿」送上來，雲端用 Workers AI 萃成知識卡。
// body {email, password, page_name, text}。認證同 /portal/daemon/config（帳密）。
//
// 🔴 t181（leo 08-04：「daemon 的 AI 改用 workers AI」，列為**最優先**——
//    「這是我的用戶最大障礙，造成首輪測試用戶的好評或惡評」）：
//    舊路徑要用戶自己去 Google 申請 Gemini API Key，實測撞到三種災難：
//      ① 完全不知道要去哪裡設定（台大資工碩士都卡住 ⇒ leo：「一般人就完蛋了」）
//      ② 拿到的金鑰所屬 Google 帳號被 flag ⇒ 403 PERMISSION_DENIED、換專案也無效
//      ③ 52 檔全滅還要把金鑰傳給別人實打才查得出真因
//    ⇒ 走 Workers AI（`env.AI` binding）**完全不需要任何金鑰**，
//      用的是用戶自己 CF 帳號內建的 AI；他的 Google 帳號被封也不受影響。
//
// 為什麼萃取放雲端而不是 daemon 直接打：daemon 端**沒有 AI binding**
//（binding 是 Worker 專屬），且模型選型集中在雲端才能統一換。
// ⚠️ 隱私邊界不變：daemon 送的是**已在本機轉成文字的原稿**，回傳的是知識卡；
//    原始檔案（docx/pdf）仍然不出用戶的電腦。
// 🔑 認證用 `X-Arcrun-API-Key`（＝namespace），**不是帳密**：
//    daemon 的密碼**只在連線精靈當下用過就丟、不落地**（config.json 沒有密碼欄，刻意的安全設計），
//    但背景萃取是每輪自動跑的 ⇒ 根本拿不到密碼。
//    而 daemon 送卡片上雲時本來就帶這個 header（collector/direct.go:355），沿用同一把最自然。
portalRouter.post('/portal/daemon/extract', (c) =>
  run(c, async () => {
    // 🔴 t189（leo 08-04 實撞：geek6688 萃取回 401「X-Arcrun-API-Key 不正確」）：
    //
    //	t181 那一輪我改成 `apiKey !== portalTenant(c.env)` 就 401，
    //	**但那假設了「daemon 的 api_key ＝ 實例的 CONSOLE_TENANT」——這個假設是錯的**：
    //	  geek6688：實例 tenant = ckxt8yr9，daemon config 的 api_key = yuga3bse ⇒ 永遠 401
    //	  youlin  ：兩者碰巧都是 yuga3bse ⇒ 「看起來是好的」
    //	又一次「在 A 能動不代表 B 能動」（與 t188 同源）。
    //	⚠️ 當時我還寫了「key 錯就 401」的測試，**把錯誤假設固化成綠燈**——
    //	   測試只證明「符合我的假設」，不證明「假設是對的」。
    //
    //	正解＝照本 repo 既有慣例：這把 key 是**租戶識別**，不是要比對的共用密語
    //	（見 `webhooks-named.ts` 的 `owner_id: apiKey` 用法）。
    //	本端點只用實例自己的 `env.AI` 生成、**不寫任何資料**、不回傳庫內內容
    //	⇒ 有帶 key 即可，不做等值比對。
    const apiKey = (c.req.header('X-Arcrun-API-Key') ?? '').trim();
    if (!apiKey) return c.json({ error: '缺少 X-Arcrun-API-Key header' }, 401);

    const body = (await c.req.json().catch(() => null)) as
      | { page_name?: string; text?: string }
      | null;
    const pageName = String(body?.page_name ?? '').trim();
    const srcText = String(body?.text ?? '');
    if (!pageName || !srcText.trim()) return c.json({ error: 'page_name 與 text 必填' }, 400);

    if (!c.env.AI) {
      // 誠實失敗：不假裝成功，並指名這個部署缺什麼（禁假綠）
      return c.json({ error: '這個部署沒有綁定 Workers AI（wrangler.toml 需有 [ai] binding），請更新知識庫版本' }, 501);
    }

    // 提示詞與 daemon 端 gemmaPrompt 同一份契約（第一行必須是「# <頁名>」），
    // 兩邊要一起改；daemon 端在 collector/extract_gemma.go。
    // 註：關聯段用的是「知識卡三元組」格式（主詞/謂詞/受詞），與 Arcrun 工作流的邊無關。
    const REL = '>'.repeat(2);
    const prompt =
      `把以下原稿重寫成定稿知識卡（正體中文）。直接輸出卡片本身：第一行必須是「# ${pageName}」，` +
      `不要任何前言、思考過程、英文草稿或說明。格式：\n# ${pageName}\n## 一句話定義\n（一行）\n` +
      `## 要點\n- （3-12 條，具體、含數字條件）\n## 關鍵實體\n- **實體名** — 一句說明\n` +
      `## 關聯\n- 實體A ${REL} 關係 ${REL} 實體B（3-8 行，用上面實體名）\n\n原稿：\n${srcText}`;

    try {
      // 模型與 workers_ai_chat recipe 同一支（選型實測見 api-recipe-seeds.ts:140：
      // llama-4-scout 2373ms／答案最完整；對照 Gemini gemma-4-31b-it 16.87 秒且吐英文草稿）。
      const out = (await c.env.AI.run('@cf/meta/llama-4-scout-17b-16e-instruct', {
        messages: [{ role: 'user', content: prompt }],
        max_tokens: 2048,
        temperature: 0.2,
      } as never)) as { response?: string } | undefined;
      const card = String(out?.response ?? '').trim();
      if (!card) return c.json({ error: 'Workers AI 沒有回傳內容' }, 502);
      // 淨化：模型偶爾在卡片前多帶一段前言 ⇒ 取最後一個「# <頁名>」起（同 daemon cleanGemmaCard）
      const marker = `# ${pageName}`;
      const idx = card.lastIndexOf(marker);
      return c.json({ success: true, card: (idx >= 0 ? card.slice(idx) : card).trim() + '\n' });
    } catch (e) {
      return c.json({ error: `Workers AI 執行失敗：${e instanceof Error ? e.message : String(e)}` }, 502);
    }
  }),
);

portalRouter.post('/portal/daemon/libraries', (c) =>
  run(c, async () => {
    const body = (await c.req.json().catch(() => null)) as
      | { email?: string; password?: string; libraries?: { name?: string; display_name?: string }[] }
      | 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: '登入失敗次數過多，請稍後再試' }, 429);
    // D61：與 /portal/login 共用同一支查找＋驗證（含「剛建好還沒傳播」的加速器重試）
    const { rec, ok } = await findAndVerifyUser(c.env, email, password);
    if (!rec || (rec.values.status ?? '') !== 'active' || !ok) {
      await recordLoginFail(c.env, email);
      return c.json({ error: 'email 或密碼錯誤' }, 401);
    }
    await clearLoginFail(c.env, email);

    const wanted = Array.isArray(body?.libraries) ? body!.libraries! : [];
    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);
    const have = new Set(existing.map((l) => String(l.values.name ?? '')));
    const ns = portalNamespace(c.env);
    const created: string[] = [];
    for (const item of wanted) {
      const name = String(item?.name ?? '').trim();
      if (!isValidLibraryName(name) || name === '*' || have.has(name)) continue;
      const res = await kbdbFetch(c.env, '/records', {
        method: 'POST',
        body: JSON.stringify({
          template: LIBRARY_TEMPLATE,
          owner_id: ns,
          values: {
            name,
            display_name: String(item?.display_name ?? '').trim() || name,
            description: '同步小幫手看守的資料夾',
            status: 'active',
          },
        }),
      });
      if (!res.ok) throw new KbdbError(`POST /records（portal_library）→ ${res.status}`);
      have.add(name);
      created.push(name);
    }
    const after = await listRecordsByTemplate(c.env, LIBRARY_TEMPLATE);
    // t135：記下本次 daemon 回報的所有庫名（48h TTL）供 GET /portal/admin/libraries 顯示「未同步」提示。
    const activeNames = wanted.map((item) => String(item?.name ?? '').trim()).filter(Boolean);
    if (activeNames.length > 0) {
      await c.env.WEBHOOKS.put(daemonActiveKey(c.env), JSON.stringify(activeNames), { expirationTtl: 172800 });
    }
    return c.json({ success: true, created, libraries: after.map(toPublicLibrary) });
  }),
);

// t176：t122 的 extractor_config（雲端指定地端萃取引擎）整組移除——
// interface／KV key／讀取函式都不再需要，因為雲端已不下發、也不再有設定入口。
// ⚠️ 舊實例的 KV 殘值無害：daemon 端 t176 起也不吃這個欄位了。

// POST /portal/daemon/config — body {email, password}。同步小幫手憑「用戶剛設的帳密」
// 直接換到自己的設定（t54，leo 07-25：「最好的就是把它的帳密直接輸入」）——
// 用戶不必再下載 config.json 丟隱藏資料夾，托盤第一次開啟輸入網址＋帳密就上工。
// 認證＝與 /portal/login 同一把（同樣吃節流與停用檢查）；回傳只含連線設定，不含任何知識內容。
// t122：extractor 改讀雲端設定（未設→預設 gemma；gemma+金鑰→一併下發金鑰）。
portalRouter.post('/portal/daemon/config', (c) =>
  run(c, async () => {
    const body = (await c.req.json().catch(() => null)) as { email?: string; password?: string } | 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);
    }
    // D61：同上，共用 findAndVerifyUser
    const { rec, ok } = await findAndVerifyUser(c.env, email, password);
    if (!rec) {
      await recordLoginFail(c.env, email);
      return c.json({ error: 'email 或密碼錯誤' }, 401);
    }
    if ((rec.values.status ?? '') !== 'active') return c.json({ error: '帳號已停用' }, 403);
    if (!ok) {
      await recordLoginFail(c.env, email);
      return c.json({ error: 'email 或密碼錯誤' }, 401);
    }
    await clearLoginFail(c.env, email);
    // t176（leo 08-03 架構翻案）：**不再下發任何 LLM 設定**（extractor／金鑰／模型）。
    // 地端用哪個模型、哪把金鑰，由使用者在同步小幫手的托盤「AI 設定…」自己設。
    //
    // 為什麼拔掉：extractor_config 這把 KV 的 key 是 `${portalTenant(env)}:portal:extractor_config`，
    // 而 portalTenant 是 **worker 層級**環境變數（見本檔 43 行）＝**全租戶共用一把**。
    // 任一處設了 claude，所有人的 daemon 都會收到 claude；沒裝 Claude Code 的機器
    // 會萃取全滅，而 portal 的 Claude 勾選框又恆為 disabled（daemon 從未回報 has_claude）
    // ⇒ 用戶自己解不開（08-03 封測實證：雲端同步成功、金鑰有效，卻零張卡）。
    // leo：「地端要用什麼模型就在 daemon 上輸入 API Key 設置，而不是雲端設置後控制地端」。
    //
    // ⚠️ 只拔 LLM 欄位——連線欄位（cypher_url／namespace／library）與本 route 本身照舊，
    // daemon 靠它上線；資料夾/庫管理（daemon/libraries）也完全不動（leo 明確劃界）。
    // #108：這裡下發給小幫手的 namespace 決定了它把知識**寫**到哪一格。
    // 以前給的是帳號層字串（CONSOLE_TENANT），與 CLI／MCP 用的實例 namespace 是兩個來源
    // ⇒ 寫進去的地方和讀出來的地方可以各自漂。改成同一個 knowledgeOwner，一台實例一個值。
    const daemonCfg: Record<string, string> = {
      cypher_url: new URL(c.req.url).origin,
      namespace: knowledgeOwner(c.env),
      library: 'kb',
      email,
      instance_name: String(rec.values.display_name ?? ''),
    };
    return c.json({ success: true, config: daemonCfg });
  }),
);

// t176：t131 這一版 `/portal/admin/ai`（POST/GET）**整組移除**。兩個原因：
//  ① 它是**重複註冊**——本檔後段（arcrun-rag#10 那版）另有一組同路徑 route。
//     Hono 先到先比 ⇒ 舊的這組一直贏，後段那組修好的「金鑰真的寫進 credentials」形同死碼。
//     這正是「key 從來沒存進去」的 bug 在 merge 後仍可能復發的原因。
//  ② 它把 use_claude_for_extract 同步進 extractor_config 下發給 daemon
//     （syncExtractorFromAiConfig），而那把 KV 是**全租戶共用**——正是 08-03 事故根因。
// 保留的是後段那組（只管 Gemini 金鑰，走 storeCredential 唯一寫入路徑，不碰 extractor）。

// t176：`POST /portal/daemon/report-capabilities` 已移除。
// 它的用途是收 daemon 回報的 has_claude 去解鎖 portal 的 Claude 勾選框；
// 但 daemon 端從未實作這個呼叫（arcrun-rag 全庫 grep = 0 命中），
// 導致 daemon_caps KV 永遠空、勾選框恆 disabled。t176 起地端模型由小幫手自己設，
// 這條回報鏈整條不需要了。

// POST /portal/admin/chat-key — body {key}。保留舊端點相容（新 UI 走 /portal/admin/ai）。
// 舊版 setup checklist / 舊 UI 仍走這裡；只更新 rag_chat workflow，不同步 ai_config。
portalRouter.post('/portal/admin/chat-key', (c) =>
  run(c, async () => {
    const auth = await requirePortalAdmin(c);
    if (!auth.ok) return auth.res;
    const body = (await c.req.json().catch(() => null)) as { key?: string } | null;
    const key = String(body?.key ?? '').trim();
    if (!key) return c.json({ error: '請貼上你的 Google AI 金鑰' }, 400);
    const tenant = knowledgeOwner(c.env);
    const kvKey = `${tenant}:wf:rag_chat`;
    const raw = await c.env.WEBHOOKS.get(kvKey, 'text');
    if (!raw) return c.json({ error: '這個實例沒有安裝 AI 問答工作流' }, 404);
    let record: Record<string, unknown>;
    try {
      record = JSON.parse(raw) as Record<string, unknown>;
    } catch {
      return c.json({ error: 'AI 問答工作流記錄損壞，請重新安裝' }, 500);
    }
    // 結構不動、只換金鑰值：走遍 graph/config，凡 x-goog-api-key 欄一律設為新值
    //（現值可能是 {{credential.gemini_api_key}} 佔位、空字串或舊 key，都直接覆蓋）。
    let replaced = 0;
    const visit = (o: unknown): void => {
      if (Array.isArray(o)) { o.forEach(visit); return; }
      if (o && typeof o === 'object') {
        const rec = o as Record<string, unknown>;
        for (const k of Object.keys(rec)) {
          if (k.toLowerCase() === 'x-goog-api-key') { rec[k] = key; replaced += 1; }
          else visit(rec[k]);
        }
      }
    };
    visit(record['graph']);
    visit(record['config']);
    if (replaced === 0) return c.json({ error: '工作流裡找不到金鑰欄位，請重新安裝後再試' }, 500);
    await c.env.WEBHOOKS.put(kvKey, JSON.stringify(record));
    return c.json({ success: true, replaced });
  }),
);

// t176：`POST|GET /portal/admin/extractor`（t122）已移除。
// 這是「雲端指定地端萃取引擎」的舊入口，且**沒有任何伺服器端驗證**——
// 只要打這條就能把 extractor_config 設成 claude，而那把 KV 全租戶共用
// ⇒ 所有沒裝 Claude Code 的機器萃取全滅（08-03 事故）。
// 地端模型現由同步小幫手托盤「AI 設定…」自己設，雲端不再有這個概念。

// GET /portal/admin/libraries — 庫目錄列表。
// t52（leo 2026-07-26：「地端 2 個資料夾、雲端就要 2 個庫，只有一個一定被罵」）：
// 除了登記簿裡的庫，**也把資料裡實際蓋過章的庫一併列出**（標 auto:true）——
// 蓋章即現身，用戶不必先去登記；登記簿只負責顯示名／圖譜來源這些額外設定。
// t135：讀 daemon 最近回報的 active libs（KV TTL 48h），已登記的庫若不在其中標 daemon_watching:false。
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);
    // 讀 daemon 最近回報的 active lib names（若 KV 不存在 = daemon 從未回報，不標 hint）
    let daemonActive: Set<string> | null = null;
    try {
      const raw = await c.env.WEBHOOKS.get(daemonActiveKey(c.env), 'text');
      if (raw) daemonActive = new Set((JSON.parse(raw) as string[]).map((n) => String(n).trim()));
    } catch { /* KV 不可達不擋主流程 */ }
    const out = libs.map((rec) => {
      const lib = toPublicLibrary(rec);
      const watching = daemonActive === null ? undefined : daemonActive.has(lib.name);
      return { ...lib, ...(watching !== undefined ? { daemon_watching: watching } : {}) };
    });
    const known = new Set(out.map((l) => l.name));
    // t142：資料面實際出現的庫＋統計數字（卡數、三元組數）並行撈取，避免 N+1。
    // 任一端點失敗不擋登記簿列表（誠實降級：stats 保持 0，不炸主流程）。
    try {
      const tenant = knowledgeOwner(c.env);
      const ownerParam = ownerQuery(tenant);
      const [autoRes, cardRes, tripletRes] = await Promise.all([
        kbdbFetch(c.env, `/entries/libraries?${ownerParam}`).catch(() => null),
        kbdbFetch(c.env, `/entries/library-stats?${ownerParam}`).catch(() => null),
        kbdbFetch(c.env, `/records/triplet-stats?${ownerParam}`).catch(() => null),
      ]);
      // 解析統計，建成 Map 供 O(1) 查找
      const cardMap = new Map<string, number>();
      if (cardRes?.ok) {
        const body = (await cardRes.json()) as { stats?: { library: string; card_count: number }[] };
        for (const s of body.stats ?? []) cardMap.set(s.library, s.card_count);
      }
      const tripletMap = new Map<string, number>();
      if (tripletRes?.ok) {
        const body = (await tripletRes.json()) as { stats?: { library: string; triplet_count: number }[] };
        for (const s of body.stats ?? []) tripletMap.set(s.library, s.triplet_count);
      }
      // 已登記庫補入統計
      for (const lib of out) {
        (lib as Record<string, unknown>).card_count = cardMap.get(lib.name) ?? 0;
        (lib as Record<string, unknown>).triplet_count = tripletMap.get(lib.name) ?? 0;
      }
      // 資料面自動出現的庫（蓋章即現身）
      if (autoRes?.ok) {
        const body = (await autoRes.json()) as { libraries?: string[] };
        for (const name of body.libraries ?? []) {
          const n = String(name ?? '').trim();
          // general 是系統內部「未標庫」桶（未標記 entry 的 fallback），不在用戶目錄露臉
          if (!n || n === 'general' || known.has(n)) continue;
          known.add(n);
          const watching = daemonActive === null ? undefined : daemonActive.has(n);
          out.push({
            record_id: '', name: n, display_name: n,
            description: '資料同步時自動出現（可在此補顯示名）',
            status: 'active', graph_source: false, auto: true,
            card_count: cardMap.get(n) ?? 0,
            triplet_count: tripletMap.get(n) ?? 0,
            ...(watching !== undefined ? { daemon_watching: watching } : {}),
          });
        }
      }
    } catch {
      // 資料面查不到不擋登記簿（誠實降級：至少顯示已登記的庫）
    }
    return c.json({ success: true, libraries: out, count: out.length });
  }),
);

// t160（leo 07-31：「要直通 daemon，同步，**沒有登記這回事**」）：
// 人工建庫端點 POST /portal/admin/libraries 已刪——庫只從 daemon 同步自動出現
// （/portal/daemon/libraries，t159）。現行 UI（e28e190 起）本就零呼叫此端點（死端點）；
// 人工登記只會製造對不上的空庫（07-27 leo 拿掉表單時已定調）。
// GET 列表與 PATCH（管理已存在的庫：改名/停用/graph_source）照舊。

// POST /portal/daemon/libraries — 小幫手（daemon）連線精靈時把看守資料夾的庫報上來自動登記。
// t159（2026-07-31 leo prod 實走揪出）：daemon registerLibraries（arcrun-tray main.go:505，t52）
// 一直在打這個端點，但 cypher 從來沒有它 ⇒ 404 被 daemon「失敗不擋連線」靜默吞掉
// ⇒ portal_library 登記簿永遠空 ⇒ portal「庫目錄管理」空（資料同步倒是全正常——
// triplet records 的 library slot 都在，病只在登記簿沒人寫）。
// 契約照 daemon 既有呼叫：body {email, password, libraries:[{name, display_name}]}。
// 帳密驗證＝與 /portal/session 同一套（daemon 只在精靈那一刻拿到帳密，不存）。
// 冪等：已登記（同 name）跳過——重跑精靈不堆重複。
portalRouter.post('/portal/daemon/libraries', (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);
    const items = Array.isArray(body?.libraries) ? body.libraries : [];
    if (items.length === 0) return c.json({ success: true, registered: [], skipped: [] });

    // 帳密驗證（沿用 /portal/session 的鎖定與驗證機制）
    if (await isLocked(c.env, email)) return c.json({ error: '登入失敗次數過多，請稍後再試' }, 429);
    // D61：與 /portal/login 共用同一支查找＋驗證（含「剛建好還沒傳播」的加速器重試）
    const { rec, ok } = await findAndVerifyUser(c.env, email, password);
    if (!rec || (rec.values.status ?? '') !== 'active' || !ok) {
      await recordLoginFail(c.env, email);
      return c.json({ error: 'email 或密碼錯誤' }, 401);
    }
    await clearLoginFail(c.env, email);

    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);
    const have = new Set(existing.map((l) => l.values.name ?? ''));
    const ns = portalNamespace(c.env);
    const registered: string[] = [];
    const skipped: string[] = [];
    for (const it of items) {
      const name = String(it?.name ?? '').trim();
      const displayName = String(it?.display_name ?? '').trim() || name;
      if (!isValidLibraryName(name) || name === '*') { skipped.push(name || '(空)'); continue; }
      if (have.has(name)) { skipped.push(name); continue; }
      const res = await kbdbFetch(c.env, '/records', {
        method: 'POST',
        body: JSON.stringify({
          template: LIBRARY_TEMPLATE,
          owner_id: ns,
          values: { name, display_name: displayName, description: '', status: 'active' },
        }),
      });
      if (!res.ok) throw new KbdbError(`POST /records（portal_library，daemon 登記）→ ${res.status}`);
      have.add(name);
      registered.push(name);
    }
    return c.json({ success: true, registered, skipped });
  }),
);

// 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) });
  }),
);

// ── AI 設定（arcrun-rag#10）────────────────────────────────────────────────────
//
// 🔴 為什麼這段存在（2026-08-01 真因，別再讓它消失）：
//   前端設定頁**一直**在打 `GET|POST /portal/admin/ai`，但**後端從來沒有這條 route**
//   ⇒ 用戶填 Gemini key → 404 → **key 從來沒被存進任何地方**，畫面卻像存好了（藍字＝假綠）。
//   leo 實撞成「重裝後 key 不見」，但真相是「從來沒存進去，所以重填也沒用」。
//   產物層鐵證：bundle tier2/ui grep 'portal/admin/ai'=1、tier2/cypher=0。
//
// 設計約束：
//   - **不另造第二套儲存**：POST 內部轉呼 credentials.ts 既有的 `storeCredential()`
//     （唯一寫入路徑＝Workers Secret 明文 + D1 目錄列 ref）。
//   - **永不回傳 key 本身**（D36）：GET 只回 `has_key` 布林。
//   - credential 的 `api_key` 欄＝租戶 slug（`portalTenant`），與安裝器 seedCredential
//     寫 `kbdb_internal_token` 用的 ns 同一個值 ⇒ 兩者落在同一租戶分區，查得到彼此。
//   - t176：Claude 偏好（use_claude_for_extract／claude_available）已整組移除——
//     地端用哪個模型由同步小幫手自己設，雲端不再有這個概念。這裡只管 Gemini 金鑰。

// GET /portal/admin/ai — 回 AI 設定現況（role=admin 閘）。**只回 has_key 布林，永不回 key**。
portalRouter.get('/portal/admin/ai', (c) =>
  run(c, async () => {
    const auth = await requirePortalAdmin(c);
    if (!auth.ok) return auth.res;

    const tenantSlug = portalTenant(c.env);
    let hasKey = false;
    try {
      // D38 圍牆修復（2026-08-07）：改走 credentials.ts 的 KBDB 目錄查詢，不直連 D1。
      hasKey = await hasCredential(c.env, tenantSlug, 'gemini_api_key');
    } catch {
      // KBDB 不可達 ⇒ 當作沒設定（不擋頁面），但也不假裝有
      hasKey = false;
    }

    // t176：不再有 claude_available／use_claude_for_extract——地端用哪個模型
    // 由同步小幫手自己設，雲端不介入（leo 08-03）。
    return c.json({ success: true, has_key: hasKey });
  }),
);

// GET /portal/admin/execution-log-retention — 讀本實例的執行紀錄保留期設定（P7，role=admin 閘）。
// retention_days: number＝自訂天數；null＝已設「不刪除」（企業稽核）；未設定過的租戶也回一個值
// （KBDB 端會退回預設 90 天，見 kbdb/src/actions/execution-log.ts DEFAULT_RETENTION_DAYS）。
portalRouter.get('/portal/admin/execution-log-retention', (c) =>
  run(c, async () => {
    const auth = await requirePortalAdmin(c);
    if (!auth.ok) return auth.res;
    const ownerId = knowledgeOwner(c.env);
    const res = await kbdbFetch(c.env, `/execution-log/retention?${ownerQuery(ownerId)}`);
    if (!res.ok) throw new KbdbError(`GET /execution-log/retention → ${res.status}`);
    const data = (await res.json()) as { retention_days?: number | null; default_days?: number };
    return c.json({ success: true, retention_days: data.retention_days ?? null, default_days: data.default_days ?? 90 });
  }),
);

// PUT /portal/admin/execution-log-retention — 設定保留天數（P7，role=admin 閘）。
// body: { retention_days: number|null }。null＝不刪除（leo 08-07：「我願意花很多錢保存，
// 不要刪除」，這是稽核用途的付費理由，不是成本負擔）；正整數＝自訂天數，覆蓋預設 90 天。
portalRouter.put('/portal/admin/execution-log-retention', (c) =>
  run(c, async () => {
    const auth = await requirePortalAdmin(c);
    if (!auth.ok) return auth.res;
    const body = (await c.req.json().catch(() => null)) as { retention_days?: number | null } | null;
    const days = body?.retention_days;
    if (days !== null && days !== undefined && (typeof days !== 'number' || !Number.isFinite(days) || days <= 0)) {
      return c.json({ error: 'retention_days 必須是正整數，或 null（代表不刪除）' }, 400);
    }
    const ownerId = knowledgeOwner(c.env);
    const res = await kbdbFetch(c.env, '/execution-log/retention', {
      method: 'PUT',
      body: JSON.stringify({ owner_id: ownerField(ownerId), retention_days: days === undefined ? null : days }),
    });
    if (!res.ok) throw new KbdbError(`PUT /execution-log/retention → ${res.status}`);
    const data = (await res.json()) as { retention_days?: number | null };
    return c.json({ success: true, retention_days: data.retention_days ?? null });
  }),
);

// DELETE /portal/admin/libraries/by-name/:name — 移除 auto 庫（只有資料章記、無登記簿 record）。
// 語意：把該庫的所有 entries 標 deprecated → 資料不刪、重新 ingest 可還原。
// ⚠️ 影響資料可搜性，要求 body.confirm 等於庫名才執行（二次確認）。
// ⚠️ 此路由必須在 DELETE /:id 之前宣告（Hono 先到先比；by-name 否則被當成 :id）。
portalRouter.delete('/portal/admin/libraries/by-name/:name', (c) =>
  run(c, async () => {
    const auth = await requirePortalAdmin(c);
    if (!auth.ok) return auth.res;
    const name = decodeURIComponent(c.req.param('name'));
    const body = await c.req.json().catch(() => null);
    const confirm = String(body?.confirm ?? '').trim();
    if (!confirm) return c.json({ error: 'body 須帶 { confirm: "<庫名>" } 才執行（移除會影響資料可搜性）' }, 400);
    if (confirm !== name) return c.json({ error: `confirm 值「${confirm}」與庫名「${name}」不符` }, 400);
    const ownerId = knowledgeOwner(c.env);
    const res = await kbdbFetch(c.env, '/entries/deprecate-by-library', {
      method: 'PATCH',
      body: JSON.stringify({ owner_id: ownerField(ownerId), library: name }),
    });
    if (!res.ok) throw new KbdbError(`PATCH /entries/deprecate-by-library → ${res.status}`);
    const data = (await res.json()) as { deprecated_count?: number };
    return c.json({
      success: true,
      deprecated_count: data.deprecated_count ?? 0,
      message: `已從自動清單移除「${name}」（共標記 ${data.deprecated_count ?? 0} 筆資料不可搜）。資料保留可還原——重新同步時會再出現。`,
    });
  }),
);

// POST /portal/admin/ai — 存 Gemini key（role=admin 閘）。body: { gemini_api_key: string }
// t176：Claude 偏好欄位已移除（地端模型由同步小幫手自己設，雲端不下發）。
portalRouter.post('/portal/admin/ai', (c) =>
  run(c, async () => {
    const auth = await requirePortalAdmin(c);
    if (!auth.ok) return auth.res;

    const body = await c.req.json().catch(() => null) as { gemini_api_key?: unknown } | null;
    const rawKey = typeof body?.gemini_api_key === 'string' ? body.gemini_api_key.trim() : '';
    if (!rawKey) {
      return c.json({ error: '沒有要變更的項目（金鑰留空）' }, 400);
    }

    const tenantSlug = portalTenant(c.env);
    try {
      // 唯一寫入路徑（credentials.ts）：Workers Secret 存值 + D1 存 ref。
      await storeCredential(c.env, tenantSlug, 'gemini_api_key', rawKey, 'gemini');
    } catch (e) {
      // 誠實回報寫入失敗——這正是本 bug 的教訓：不能讓前端以為存好了。
      return c.json(
        { error: `金鑰儲存失敗：${e instanceof Error ? e.message : String(e)}` },
        502,
      );
    }

    return c.json({ success: true, has_key: true });
  }),
);

// DELETE /portal/admin/libraries/:id — 移除已登記庫（有 record_id 的登記簿 record）。
// 只刪登記簿那筆 record；知識資料（entries with library=name）完全不動。
// 資料若有的話，重新同步後會以 auto 庫重新出現。
portalRouter.delete('/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');
    // 成員資格驗（防憑空 id 打到不相干 record）
    const libs = await listRecordsByTemplate(c.env, LIBRARY_TEMPLATE);
    const target = libs.find((l) => l.record_id === recordId);
    if (!target) return c.json({ error: '庫不存在' }, 404);
    const found = await deleteKbdbRecord(c.env, recordId);
    if (!found) return c.json({ error: '庫不存在' }, 404);
    return c.json({
      success: true,
      name: target.values.name ?? '',
      message: `已從目錄移除「${target.values.display_name ?? target.values.name ?? ''}」。資料仍在，重新同步會再出現。`,
    });
  }),
);

// ── 檢修孔核心邏輯（t213，InkStoneCo 總管交辦，2026-08-08）──────────────────────
//
// buildDiagnostics 是 GET /portal/data/diagnostics（P3 session 版，portal 網頁「疑難排解」
// 按鈕）與 GET /portal/daemon/diagnostics（下方新增，daemon 免帳密版）共用的**唯一**實作
// （薄殼原則 rule 07：能力只放一處）。原本整段邏輯躺在 portal-data.ts 的 handler 裡；
// daemon 是背景常駐行程、沒有 portal session（密碼只在連線精靈當下用過就丟，見
// connect.go 註解），構不到 session 版端點，需要一支 X-Arcrun-API-Key 版本——抽出來讓
// 兩條路由共用同一份查詢邏輯，不是各寫一份、日後各自漂移。
//
// 涵蓋「這次一定要涵蓋」的向量／embedding 健康狀態：embed 模組是否開（index 存在的前提）、
// 已嵌入／待嵌入卡片數、以及 embedSelfTest（KBDB #12）——這是唯一能分辨「從沒嵌過」與
// 「嵌了但 index 查不到自己」兩種故障模式的方法（Arcrun#11 的真實案例正是後者，光看
// 計數看不出來）。
//
// 🔴 兩條紅線（規格原文，2026-08-07 leo 直接指令）：
//   ① 不准把內部概念暴露給用戶——本函式只回統計/狀態，呼叫端文案不提 KBDB/Vectorize/
//      owner_id 這類詞。
//   ② 不准洩漏知識卡內容本體——以下每一個欄位都只挑「數字」或「布林」，即使背後的 KBDB
//      端點回應含 content（如 triplet 的 subject/object 名稱），本函式一律只讀出用得到
//      的數字後就丟掉那個回應，不把原始內容往呼叫端送。
export interface DiagnosticsCore {
  library_count: number;
  triplet_count: number;
  library_scope_check: Record<string, unknown>;
  embedding: Record<string, unknown>;
  notes: string[];
}

/**
 * tenant＝owner_id（session 版傳 `knowledgeOwner(env)`；daemon 版傳 `tenantFromApiKey(header)`）。
 * #108：型別收成 `TenantId`——診斷檔要是報了另一個命名空間的統計，等於用假數字排查真問題。
 */
export async function buildDiagnostics(env: Bindings, tenant: TenantId): Promise<DiagnosticsCore> {
  const notes: string[] = [];

  // ① embed 模組健康狀態（backfillStatus + selfTest，兩支都活在 KBDB 那面牆內）。
  let embedding: Record<string, unknown> = { checked: false };
  try {
    const [statusRes, selftestRes] = await Promise.all([
      kbdbFetch(env, `/embed/backfill/status?${ownerQuery(tenant)}`),
      kbdbFetch(env, `/embed/selftest?${ownerQuery(tenant)}`),
    ]);
    const statusBody = (await statusRes.json().catch(() => null)) as
      | { success?: boolean; enabled?: boolean; pending?: number; embedded?: number }
      | null;
    const selftestBody = (await selftestRes.json().catch(() => null)) as
      | { success?: boolean; enabled?: boolean; tested?: boolean; passed?: boolean | null; note?: string }
      | null;
    embedding = {
      checked: true,
      module_enabled: statusBody?.enabled ?? false, // Vectorize+AI binding 都在，才有「index」這回事
      cards_embedded: statusBody?.embedded ?? 0,
      cards_pending: statusBody?.pending ?? 0,
      self_test: {
        ran: selftestBody?.tested ?? false,
        // 三態：true=能搜到自己 ／ false=搜不到自己（index 收錄有缺）／ null=還沒東西可測或模組未開
        found_itself: selftestBody?.tested ? (selftestBody?.passed ?? null) : null,
        note: selftestBody?.note ?? '',
      },
    };
  } catch (e) {
    notes.push(`embed 健康狀態查詢失敗：${e instanceof Error ? e.message : String(e)}`);
  }

  // ② 卡片與知識圖譜規模（只取數字，不取內容欄位）。
  //
  // 改走與 /portal/admin/libraries 相同、驗證過在用的**即時查詢**組合（不依賴 library_map
  // 快取——2026-08-08 曾實測快取恆回 0，根因與修正過程見 commit 7dbd4f5，此處不重貼一次
  // 避免兩處各改各的漂移）：
  //   - listRecordsByTemplate(portal_library)：已登記的庫（t159）
  //   - GET /entries/libraries：資料裡實際蓋章出現過的庫，登記與否都算（t52，
  //     「蓋章即現身」）；'general' 是未標庫的系統 fallback 桶，不算使用者眼中的一個庫，
  //     與 admin/libraries 同慣例排除。
  //   - GET /records/triplet-stats：per-library 即時聚合 SQL（t142，COUNT，非快取）。
  let library_count = 0;
  let triplet_count = 0;
  const ownerParam = ownerQuery(tenant);
  try {
    const [registeredLibs, autoRes, tripletRes] = await Promise.all([
      listRecordsByTemplate(env, LIBRARY_TEMPLATE).catch(() => []),
      kbdbFetch(env, `/entries/libraries?${ownerParam}`),
      kbdbFetch(env, `/records/triplet-stats?${ownerParam}`),
    ]);
    const knownLibs = new Set(
      registeredLibs.map((r) => (r.values.name ?? '').trim()).filter((n): n is string => !!n),
    );
    const autoBody = (await autoRes.json().catch(() => null)) as { success?: boolean; libraries?: string[] } | null;
    for (const name of autoBody?.libraries ?? []) {
      const n = String(name ?? '').trim();
      if (n && n !== 'general') knownLibs.add(n);
    }
    library_count = knownLibs.size;

    const tripletBody = (await tripletRes.json().catch(() => null)) as
      | { success?: boolean; stats?: { library: string; triplet_count?: number }[] }
      | null;
    triplet_count = (tripletBody?.stats ?? []).reduce((sum, s) => sum + (Number(s.triplet_count) || 0), 0);
  } catch (e) {
    notes.push(`知識庫規模查詢失敗：${e instanceof Error ? e.message : String(e)}`);
  }

  // ②.5 統計自我檢查（呼應上面 embedding.self_test 的精神——leo 直接指令：「不要讓『查不到』
  // 和『沒有』長得一樣」）。library_count／triplet_count 兩者都是 0 時，才另外花一次查詢，
  // 用完全不同的路徑（不分庫、不分模板，只問「這個 owner_id 底下到底有沒有任何 entries」）
  // 做交叉驗證——如果探測到有資料，代表問題出在查詢方式或 owner_id 對不上（2026-08-01
  // t161 前科：手動補的 record owner_id 存成 None，kbdb_query 全量查得到、按 owner_id 過濾
  // 的畫面永遠空，比真的沒資料更難查）；如果探測也是空，才比較像真的是空庫。
  let library_scope_check: Record<string, unknown> = { ran: false };
  if (library_count === 0 && triplet_count === 0) {
    try {
      const probeRes = await kbdbFetch(env, `/entries?${new URLSearchParams({ owner_id: ownerField(tenant), limit: '1' }).toString()}`);
      const probeBody = (await probeRes.json().catch(() => null)) as { total?: number } | null;
      const total = probeBody?.total ?? 0;
      library_scope_check = {
        ran: true,
        any_entries_found: total > 0,
        note:
          total > 0
            ? `這個租戶底下查得到其他資料（entries 共 ${total} 筆），但庫／三元組統計仍回 0——像是查詢方式或租戶對不上，不像真的沒資料，需要人再查一次`
            : '這個租戶底下完全查不到任何資料——比較像是真的還沒有資料，不是查詢方式錯了',
      };
    } catch (e) {
      library_scope_check = {
        ran: true,
        any_entries_found: null,
        note: `自我探測查詢本身失敗：${e instanceof Error ? e.message : String(e)}`,
      };
    }
  }

  // ③ 最近一次萃取（daemon → /portal/daemon/extract）成功與否：目前沒有雲端側的失敗歷史
  // 記錄可讀（該端點是同步請求/回應，失敗只回給呼叫當下的 daemon，雲端不落地保存）——
  // 這個缺口不在這裡假裝補上一句話，本機那半（manifest 的 LastError 分類統計，t213 phase 2
  // arcrun-app 那半）才是真正能答這題的地方；本函式維持誠實：知道多少答多少，不摻水。
  return { library_count, triplet_count, library_scope_check, embedding, notes };
}

// GET /portal/daemon/diagnostics — 檢修孔的 daemon 版（t213 matrix/arcrun 半部，InkStoneCo
// 總管交辦，2026-08-08）。讓 arcrun-app（daemon 桌面殼）能免帳密拿到雲端這半的診斷數字，
// 跟本機那半（manifest 總量／失敗分類統計／daemon 版本與自我更新狀態，見 products/arcrun-rag
// repo t213 phase 2 的設計）合併成一份完整診斷檔——本端點純粹只負責雲端能看到的那半。
//
// 認證比照既有 /portal/daemon/extract（X-Arcrun-API-Key，不是 session）：daemon 是背景
// 常駐行程，使用者的密碼只在連線精靈當下用過就丟、不落地（見 collector/cmd/arcrun-app/
// connect.go 註解），背景查詢沒有密碼可用。
//
// apiKey 當 tenant／owner_id 用，**不與 portalTenant(env) 比對**——t189 教訓：daemon 的
// api_key 不保證等於這個 worker 的 CONSOLE_TENANT（多帳號情境下曾對不上，見上方
// /portal/daemon/extract 的 t189 註解），「有帶 key 即可」是本 repo 對 X-Arcrun-API-Key
// 的既有慣例（見 webhooks-named.ts 的 owner_id: apiKey 用法）。
//
// 邏輯與 /portal/data/diagnostics（portal-data.ts）共用同一個上面的 buildDiagnostics()——
// 薄殼原則：能力只實作一次；兩條路由只是認證層不同，回應形狀完全一致，不改既有行為。
portalRouter.get('/portal/daemon/diagnostics', (c) =>
  run(c, async () => {
    const apiKey = (c.req.header('X-Arcrun-API-Key') ?? '').trim();
    if (!apiKey) return c.json({ error: '缺少 X-Arcrun-API-Key header' }, 401);
    // 這條路的租戶來自**請求本身**（小幫手帶的 namespace），不是環境變數 → 沒有 #108 的漂移問題。
    const core = await buildDiagnostics(c.env, tenantFromApiKey(apiKey));
    return c.json({
      generated_at: new Date().toISOString(),
      instance_url: new URL(c.req.url).origin,
      bundle_version: c.env.ARCRUN_BUNDLE_VERSION ?? null,
      ...core,
    });
  }),
);
