/**
 * portal-auth P4 測試（design §5/§6，Gitea #24/#25）
 *
 * 覆蓋（=tasks.md P4＋總管派工驗收重點）：
 *   1. **最後一個 active admin 鎖死保護**：停用 → 409；降級 role=user → 409；
 *      「還有另一個 active admin」才放行；另一個 admin 是 disabled 不算數。
 *   2. 一次性密碼：新增未帶密碼 → generated_password 只在回應出現一次、明碼不落地
 *      （新家只有 pbkdf2 hash，D61 起帳號建立走認證儲存不再落 KBDB）；自帶密碼 → 回應無 generated_password。
 *   3. reset-password：回一次性新密碼；PATCH 落地的是 hash 非明碼（目標帳號沿用舊家 fixture，
 *      仍走 KBDB PATCH——見下方 mockPatchPrelude 的說明）。
 *   4. 庫權限：PATCH libraries=["*"]（全庫）合法；空陣列/壞庫名 → 400。
 *   5. 庫目錄：POST 建庫寫 {tenant}::portal 子 namespace；PATCH graph_source boolean。
 *   6. /portal HTML 殼（P4 admin 頁）：admin view 存在；**仍零租戶字串、零 /kbdb/、
 *      零 X-Arcrun-API-Key**（P3 紅線在新增 admin UI 後不得回退）。
 *
 * KBDB 打 fetchMock 假 host（wrangler.test.toml KBDB_BASE_URL=https://kbdb.test）＋
 * disableNetConnect——絕不外連。UI 全流程由本機隔離雙 worker 端到端 curl 驗證（PR 證據表）。
 *
 * 2026-08-14 起（D61 補充，leo confirm「走C」）：帳號改回住 KBDB（binding，見
 * pending-changes.md「認證儲存要不要搬回 D1/KV」）。本檔測試裡的帳號 fixture
 * （rec_admin/rec_u1/rec_admin2…）全部沿用「record_id 不是 auth: 開頭」這個既有慣例——
 * 這正是 portal.ts 的相容分流點（isAuthStoreId(recordId)），非 auth: 開頭的 id 一律走
 * KBDB 路徑，行為與 D61 之前完全一致，故本檔絕大多數測試不需要改。
 * **只有「新建帳號」這個動作**（POST /portal/admin/users、POST /portal/admin/bootstrap
 * 走同一支 createPortalUser）改回直接寫 KBDB（POST /records + POST /entries head），
 * 不再需要 `https://api.cloudflare.com/.../secrets`。
 */
import { SELF, env, fetchMock } from 'cloudflare:test';
import { beforeAll, afterEach, describe, it, expect } from 'vitest';
import { hashPassword, PBKDF2_ITERATIONS } from '../src/lib/portal-auth';

const KBDB = 'https://kbdb.test';
const NS = 'leo::portal'; // wrangler.test.toml CONSOLE_TENANT=leo → 子 namespace

let storedHash: string;

beforeAll(async () => {
  fetchMock.activate();
  fetchMock.disableNetConnect();
  storedHash = await hashPassword('unit-test-pw-1', 10_000);
});
afterEach(() => fetchMock.assertNoPendingInterceptors());

function json(method: string, path: string, body?: unknown, headers: Record<string, string> = {}) {
  return SELF.fetch(`http://localhost${path}`, {
    method,
    headers: { 'Content-Type': 'application/json', ...headers },
    body: body === undefined ? undefined : JSON.stringify(body),
  });
}

/** 建帳號的 KBDB 寫入路徑（POST /records + POST /entries head entry）。 */
function mockCreateUser(recordId: string): { recordBody: () => string } {
  let recordBody = '';
  fetchMock
    .get(KBDB)
    .intercept({ path: '/records', method: 'POST' })
    .reply(200, (opts) => {
      recordBody = String(opts.body);
      return { success: true, record: { record_id: recordId, template_id: 'tpl_pu', values: {} } };
    });
  fetchMock
    .get(KBDB)
    .intercept({ path: '/entries', method: 'POST' })
    .reply(200, { success: true, entry: { id: `${recordId}_head` } });
  return { recordBody: () => recordBody };
}

function mockHeadLookup(email: string, recordId: string | null) {
  const needle = new URLSearchParams({ page_name: email }).toString();
  fetchMock
    .get(KBDB)
    .intercept({
      path: (p: string) =>
        p.startsWith('/entries?') && p.includes(needle) && p.includes(encodeURIComponent(NS)),
      method: 'GET',
    })
    .reply(200, { success: true, entries: recordId ? [{ content: recordId }] : [], count: recordId ? 1 : 0 });
}

function mockGetRecord(recordId: string, values: Record<string, string>) {
  fetchMock
    .get(KBDB)
    .intercept({ path: `/records/${recordId}`, method: 'GET' })
    .reply(200, { success: true, record: { record_id: recordId, template_id: 'tpl_pu', values } });
}

function mockListByTemplate(template: string, records: { record_id: string; values: Record<string, string> }[]) {
  fetchMock
    .get(KBDB)
    .intercept({ path: (p: string) => p.startsWith(`/records/by-template/${template}`), method: 'GET' })
    .reply(200, { success: true, records: records.map((r) => ({ ...r, template_id: 'tpl' })), count: records.length });
}

function mockTemplatesExist() {
  for (const name of ['portal_user', 'portal_library', 'triplet']) {
    fetchMock
      .get(KBDB)
      .intercept({ path: `/templates/${name}`, method: 'GET' })
      .reply(200, { success: true, template: { id: `tpl-${name}`, name } });
  }
}

function adminValues(overrides: Record<string, string> = {}): Record<string, string> {
  return {
    email: 'admin@example.com',
    display_name: '管理員',
    status: 'active',
    role: 'admin',
    password_hash: storedHash,
    libraries: '["*"]',
    created_at: '2026-07-14T00:00:00.000Z',
    updated_at: '2026-07-14T00:00:00.000Z',
    ...overrides,
  };
}

function userValues(overrides: Record<string, string> = {}): Record<string, string> {
  return adminValues({ email: 'user@example.com', display_name: '同仁', role: 'user', libraries: '["general"]', ...overrides });
}

async function seedAdminSession(token = 'tok-admin', recordId = 'rec_admin') {
  await env.SESSIONS_KV.put(`portal_sess:${token}`, JSON.stringify({ record_id: recordId }));
}

/** PATCH /portal/admin/users/:id 的共通 mock 前奏：admin session 回讀＋目標 record 成員驗證。 */
function mockPatchPrelude(targetId: string, targetValues: Record<string, string>) {
  mockGetRecord('rec_admin', adminValues()); // requirePortalAdmin 回讀
  mockGetRecord(targetId, targetValues); // assertPortalUserRecord 回讀目標
  mockHeadLookup(targetValues.email, targetId); // head 指回同 record → 成員資格成立
}

// ═══════════════ 1. 最後一個 active admin 鎖死保護 ═══════════════

describe('last-admin 鎖死保護（PATCH /portal/admin/users/:id）', () => {
  it('停用最後一個 active admin → 409，不發 PATCH', async () => {
    await seedAdminSession();
    mockPatchPrelude('rec_admin2', adminValues({ email: 'admin2@example.com' }));
    // guard 查全列表：只有目標自己是 active admin（另一人是一般 user）
    mockListByTemplate('portal_user', [
      { record_id: 'rec_admin2', values: adminValues({ email: 'admin2@example.com' }) },
      { record_id: 'rec_u1', values: userValues() },
    ]);
    const res = await json('PATCH', '/portal/admin/users/rec_admin2', { status: 'disabled' }, { Authorization: 'Bearer tok-admin' });
    expect(res.status).toBe(409);
    const data = (await res.json()) as { error: string };
    expect(data.error).toContain('最後一個管理員');
  });

  it('降級最後一個 active admin（role=user）→ 409', async () => {
    await seedAdminSession();
    mockPatchPrelude('rec_admin2', adminValues({ email: 'admin2@example.com' }));
    mockListByTemplate('portal_user', [
      { record_id: 'rec_admin2', values: adminValues({ email: 'admin2@example.com' }) },
    ]);
    const res = await json('PATCH', '/portal/admin/users/rec_admin2', { role: 'user' }, { Authorization: 'Bearer tok-admin' });
    expect(res.status).toBe(409);
  });

  it('「另一個 admin 是 disabled」不算數 → 仍 409', async () => {
    await seedAdminSession();
    mockPatchPrelude('rec_admin2', adminValues({ email: 'admin2@example.com' }));
    mockListByTemplate('portal_user', [
      { record_id: 'rec_admin2', values: adminValues({ email: 'admin2@example.com' }) },
      { record_id: 'rec_admin3', values: adminValues({ email: 'admin3@example.com', status: 'disabled' }) },
    ]);
    const res = await json('PATCH', '/portal/admin/users/rec_admin2', { status: 'disabled' }, { Authorization: 'Bearer tok-admin' });
    expect(res.status).toBe(409);
  });

  it('還有另一個 active admin → 停用放行（200）', async () => {
    await seedAdminSession();
    mockPatchPrelude('rec_admin2', adminValues({ email: 'admin2@example.com' }));
    mockListByTemplate('portal_user', [
      { record_id: 'rec_admin2', values: adminValues({ email: 'admin2@example.com' }) },
      { record_id: 'rec_admin', values: adminValues() }, // 操作者自己也是 active admin
    ]);
    fetchMock
      .get(KBDB)
      .intercept({ path: '/records/rec_admin2', method: 'PATCH' })
      .reply(200, {
        success: true,
        record: { record_id: 'rec_admin2', template_id: 'tpl_pu', values: adminValues({ email: 'admin2@example.com', status: 'disabled' }) },
      });
    const res = await json('PATCH', '/portal/admin/users/rec_admin2', { status: 'disabled' }, { Authorization: 'Bearer tok-admin' });
    expect(res.status).toBe(200);
  });

  it('停用一般 user 不觸發 admin 列表檢查（無 by-template mock 也過＝機械證明沒多打）', async () => {
    await seedAdminSession();
    mockPatchPrelude('rec_u1', userValues());
    fetchMock
      .get(KBDB)
      .intercept({ path: '/records/rec_u1', method: 'PATCH' })
      .reply(200, {
        success: true,
        record: { record_id: 'rec_u1', template_id: 'tpl_pu', values: userValues({ status: 'disabled' }) },
      });
    const res = await json('PATCH', '/portal/admin/users/rec_u1', { status: 'disabled' }, { Authorization: 'Bearer tok-admin' });
    expect(res.status).toBe(200); // afterEach assertNoPendingInterceptors ＝ 沒有殘留 list mock
  });
});

// ═══════════════ 2. 一次性密碼（新增帳號）═══════════════

describe('POST /portal/admin/users（一次性密碼）', () => {
  it('未帶 password → generated_password 回一次（16 碼）；KBDB 落的是 hash 非明碼', async () => {
    await seedAdminSession();
    mockGetRecord('rec_admin', adminValues());
    mockHeadLookup('new@example.com', null); // email 未占用
    const { recordBody } = mockCreateUser('rec_new');
    mockGetRecord('rec_new', userValues({ email: 'new@example.com' })); // 回應用的回讀
    const res = await json(
      'POST',
      '/portal/admin/users',
      { email: 'new@example.com', display_name: '新同仁', libraries: ['general'] },
      { Authorization: 'Bearer tok-admin' },
    );
    expect(res.status).toBe(200);
    const data = (await res.json()) as { generated_password?: string; user: Record<string, unknown> };
    expect(typeof data.generated_password).toBe('string');
    expect(data.generated_password!.length).toBe(16);
    expect('password_hash' in data.user).toBe(false);
    expect((data.user as { record_id: string }).record_id).toBe('rec_new'); // 住 KBDB

    // 一次性密碼不落地：KBDB 收到的 record body 只有 hash、無明碼
    expect(recordBody()).not.toContain(data.generated_password!);
    const rec = JSON.parse(recordBody()) as { owner_id: string; values: Record<string, string> };
    expect(rec.owner_id).toBe(NS);
    expect(rec.values.password_hash.startsWith(`pbkdf2-sha256$${PBKDF2_ITERATIONS}$`)).toBe(true);
  });

  it('自帶 password → 回應**無** generated_password', async () => {
    await seedAdminSession();
    mockGetRecord('rec_admin', adminValues());
    mockHeadLookup('own@example.com', null);
    mockCreateUser('rec_own');
    mockGetRecord('rec_own', userValues({ email: 'own@example.com' }));
    const res = await json(
      'POST',
      '/portal/admin/users',
      { email: 'own@example.com', password: 'self-chosen-pw-1' },
      { Authorization: 'Bearer tok-admin' },
    );
    expect(res.status).toBe(200);
    const data = (await res.json()) as Record<string, unknown>;
    expect('generated_password' in data).toBe(false);
  });
});

// ═══════════════ 3. reset-password ═══════════════

describe('POST /portal/admin/users/:id/reset-password', () => {
  it('回一次性新密碼；PATCH 落 KBDB 的是新 hash 非明碼', async () => {
    await seedAdminSession();
    mockPatchPrelude('rec_u1', userValues());
    let patched = '';
    fetchMock
      .get(KBDB)
      .intercept({ path: '/records/rec_u1', method: 'PATCH' })
      .reply(200, (opts) => {
        patched = String(opts.body);
        return { success: true, record: { record_id: 'rec_u1', template_id: 'tpl_pu', values: userValues() } };
      });
    const res = await json('POST', '/portal/admin/users/rec_u1/reset-password', undefined, { Authorization: 'Bearer tok-admin' });
    expect(res.status).toBe(200);
    const data = (await res.json()) as { password: string };
    expect(typeof data.password).toBe('string');
    expect(data.password.length).toBe(16);
    expect(patched).not.toContain(data.password); // 明碼不落 KBDB
    const sent = JSON.parse(patched) as { values: Record<string, string> };
    expect(sent.values.password_hash.startsWith(`pbkdf2-sha256$${PBKDF2_ITERATIONS}$`)).toBe(true);
    expect(sent.values.password_hash).not.toBe(storedHash); // 真的換了
  });
});

// ═══════════════ 3.5 recover-password（arcrun-rag#25：admin 忘記 portal 密碼自救）═══════════════

describe('POST /portal/admin/recover-password', () => {
  it('無 console owner session → 401，不碰 KBDB', async () => {
    const res = await json('POST', '/portal/admin/recover-password', { email: 'admin@example.com' });
    expect(res.status).toBe(401);
  });

  it('有 console session 但 email 格式不對 → 400，不碰 KBDB', async () => {
    await env.SESSIONS_KV.put('console_sess:owner-token', JSON.stringify({ created_at: Date.now() }));
    const res = await json(
      'POST',
      '/portal/admin/recover-password',
      { email: 'not-an-email' },
      { Authorization: 'Bearer owner-token' },
    );
    expect(res.status).toBe(400);
  });

  it('查無此 email 的 portal 帳號 → 404，不誤導成別種錯誤', async () => {
    await env.SESSIONS_KV.put('console_sess:owner-token', JSON.stringify({ created_at: Date.now() }));
    mockHeadLookup('ghost@example.com', null);
    const res = await json(
      'POST',
      '/portal/admin/recover-password',
      { email: 'ghost@example.com' },
      { Authorization: 'Bearer owner-token' },
    );
    expect(res.status).toBe(404);
  });

  it('console session 有效＋帳號存在 → 回一次性新密碼；PATCH 落 KBDB 的是新 hash 非明碼；**不需要任何 portal session**', async () => {
    await env.SESSIONS_KV.put('console_sess:owner-token', JSON.stringify({ created_at: Date.now() }));
    // 刻意不 seedAdminSession()：這條路唯一該吃的是 console session，機械證明繞得過
    // 「忘記 portal 密碼 ⇒ 沒有 portal_sess ⇒ 打不進其他 admin 端點」這個死結。
    mockHeadLookup('admin@example.com', 'rec_admin');
    mockGetRecord('rec_admin', adminValues());
    let patched = '';
    fetchMock
      .get(KBDB)
      .intercept({ path: '/records/rec_admin', method: 'PATCH' })
      .reply(200, (opts) => {
        patched = String(opts.body);
        return { success: true, record: { record_id: 'rec_admin', template_id: 'tpl_pu', values: adminValues() } };
      });
    const res = await json(
      'POST',
      '/portal/admin/recover-password',
      { email: 'Admin@Example.com' }, // 混寫大小寫，驗證正規化成小寫再查
      { Authorization: 'Bearer owner-token' },
    );
    expect(res.status).toBe(200);
    const data = (await res.json()) as { success: boolean; email: string; password: string };
    expect(data.success).toBe(true);
    expect(data.email).toBe('admin@example.com');
    expect(typeof data.password).toBe('string');
    expect(data.password.length).toBe(16);
    expect(patched).not.toContain(data.password); // 明碼不落 KBDB
    const sent = JSON.parse(patched) as { values: Record<string, string> };
    expect(sent.values.password_hash.startsWith(`pbkdf2-sha256$${PBKDF2_ITERATIONS}$`)).toBe(true);
    expect(sent.values.password_hash).not.toBe(storedHash); // 真的換了
  });
});

// ═══════════════ 4. 庫權限勾選（libraries PATCH）═══════════════

describe('PATCH libraries（每帳號可查庫）', () => {
  it('["*"]（全庫選項）合法，存成 JSON 字串', async () => {
    await seedAdminSession();
    mockPatchPrelude('rec_u1', userValues());
    let patched = '';
    fetchMock
      .get(KBDB)
      .intercept({ path: '/records/rec_u1', method: 'PATCH' })
      .reply(200, (opts) => {
        patched = String(opts.body);
        return { success: true, record: { record_id: 'rec_u1', template_id: 'tpl_pu', values: userValues({ libraries: '["*"]' }) } };
      });
    const res = await json('PATCH', '/portal/admin/users/rec_u1', { libraries: ['*'] }, { Authorization: 'Bearer tok-admin' });
    expect(res.status).toBe(200);
    expect((JSON.parse(patched) as { values: Record<string, string> }).values.libraries).toBe('["*"]');
  });

  it('空陣列 / 壞庫名（含逗號）→ 400 不 PATCH', async () => {
    await seedAdminSession();
    mockPatchPrelude('rec_u1', userValues());
    const res = await json('PATCH', '/portal/admin/users/rec_u1', { libraries: [] }, { Authorization: 'Bearer tok-admin' });
    expect(res.status).toBe(400);

    await seedAdminSession('tok-admin2');
    mockGetRecord('rec_admin', adminValues());
    mockGetRecord('rec_u1', userValues());
    mockHeadLookup('user@example.com', 'rec_u1');
    const res2 = await json('PATCH', '/portal/admin/users/rec_u1', { libraries: ['a,b'] }, { Authorization: 'Bearer tok-admin2' });
    expect(res2.status).toBe(400);
  });
});

// ═══════════════ 5. 庫目錄管理 ═══════════════

describe('/portal/admin/libraries', () => {
  it('t160：人工建庫端點已刪（leo「沒有登記這回事」）——POST → 404；庫只從 daemon 同步來', async () => {
    // 舊測試驗「POST 建庫 200＋重複 409」——t160 拔掉人工建庫（e744ad1）後規格為：
    // 庫由 /portal/daemon/libraries（連線精靈自動登記，t159）產生，admin 只能 GET/PATCH。
    const res = await json(
      'POST',
      '/portal/admin/libraries',
      { name: 'finance', display_name: '財務庫' },
      { Authorization: 'Bearer tok-admin' },
    );
    expect(res.status).toBe(404);
  });

  it('PATCH graph_source：boolean 進、slot 存字串；非 boolean → 400', async () => {
    await seedAdminSession();
    mockGetRecord('rec_admin', adminValues());
    mockListByTemplate('portal_library', [
      { record_id: 'rec_lib1', values: { name: 'finance', display_name: '財務庫', status: 'active' } },
    ]);
    let patched = '';
    fetchMock
      .get(KBDB)
      .intercept({ path: '/records/rec_lib1', method: 'PATCH' })
      .reply(200, (opts) => {
        patched = String(opts.body);
        return {
          success: true,
          record: { record_id: 'rec_lib1', template_id: 'tpl_pl', values: { name: 'finance', status: 'active', graph_source: 'true' } },
        };
      });
    const res = await json('PATCH', '/portal/admin/libraries/rec_lib1', { graph_source: true }, { Authorization: 'Bearer tok-admin' });
    expect(res.status).toBe(200);
    expect((JSON.parse(patched) as { values: Record<string, string> }).values.graph_source).toBe('true');
    const data = (await res.json()) as { library: { graph_source: boolean } };
    expect(data.library.graph_source).toBe(true);

    await seedAdminSession('tok-admin4');
    mockGetRecord('rec_admin', adminValues());
    mockListByTemplate('portal_library', [
      { record_id: 'rec_lib1', values: { name: 'finance', status: 'active' } },
    ]);
    const bad = await json('PATCH', '/portal/admin/libraries/rec_lib1', { graph_source: 'yes' }, { Authorization: 'Bearer tok-admin4' });
    expect(bad.status).toBe(400);
  });

  it('一般 user 打庫目錄 → 403（role 閘）', async () => {
    await seedAdminSession('tok-user', 'rec_u1');
    mockGetRecord('rec_u1', userValues());
    const res = await json('GET', '/portal/admin/libraries', undefined, { Authorization: 'Bearer tok-user' });
    expect(res.status).toBe(403);
  });

  it('GET auto 庫列表過濾 general（general 是系統桶，不在用戶目錄顯示）', async () => {
    await seedAdminSession();
    mockGetRecord('rec_admin', adminValues());
    mockListByTemplate('portal_library', []);
    // t142：GET /portal/admin/libraries 現在並行呼叫三個 kbdb 端點，三個都要 mock
    fetchMock
      .get(KBDB)
      .intercept({ path: (p: string) => p.startsWith('/entries/libraries'), method: 'GET' })
      .reply(200, { libraries: ['kb', 'general', 'notes'] });
    fetchMock
      .get(KBDB)
      .intercept({ path: (p: string) => p.startsWith('/entries/library-stats'), method: 'GET' })
      .reply(200, { success: true, stats: [] });
    fetchMock
      .get(KBDB)
      .intercept({ path: (p: string) => p.startsWith('/records/triplet-stats'), method: 'GET' })
      .reply(200, { success: true, stats: [] });
    const res = await json('GET', '/portal/admin/libraries', undefined, { Authorization: 'Bearer tok-admin' });
    expect(res.status).toBe(200);
    const data = (await res.json()) as { libraries: { name: string; auto?: boolean }[] };
    const names = data.libraries.map((l) => l.name);
    expect(names).toContain('kb');
    expect(names).toContain('notes');
    expect(names).not.toContain('general');
  });
});

// ═══════════════ t142 庫目錄卡數＋三元組數 ═══════════════

describe('GET /portal/admin/libraries + stats（t142）', () => {
  it('kbdb 回傳統計 → 已登記庫帶 card_count + triplet_count', async () => {
    await seedAdminSession();
    mockGetRecord('rec_admin', adminValues());
    mockListByTemplate('portal_library', [
      { record_id: 'rec_lib_kb', values: { name: 'kb', display_name: '知識庫', status: 'active', graph_source: 'false' } },
    ]);
    fetchMock
      .get(KBDB)
      .intercept({ path: (p: string) => p.startsWith('/entries/libraries'), method: 'GET' })
      .reply(200, { libraries: ['kb'] });
    fetchMock
      .get(KBDB)
      .intercept({ path: (p: string) => p.startsWith('/entries/library-stats'), method: 'GET' })
      .reply(200, { success: true, stats: [{ library: 'kb', card_count: 42 }] });
    fetchMock
      .get(KBDB)
      .intercept({ path: (p: string) => p.startsWith('/records/triplet-stats'), method: 'GET' })
      .reply(200, { success: true, stats: [{ library: 'kb', triplet_count: 111 }] });
    const res = await json('GET', '/portal/admin/libraries', undefined, { Authorization: 'Bearer tok-admin' });
    expect(res.status).toBe(200);
    const data = (await res.json()) as { libraries: { name: string; card_count?: number; triplet_count?: number }[] };
    const kb = data.libraries.find((l) => l.name === 'kb');
    expect(kb).toBeDefined();
    expect(kb!.card_count).toBe(42);
    expect(kb!.triplet_count).toBe(111);
  });

  it('auto 庫也帶 card_count + triplet_count', async () => {
    await seedAdminSession();
    mockGetRecord('rec_admin', adminValues());
    mockListByTemplate('portal_library', []);
    fetchMock
      .get(KBDB)
      .intercept({ path: (p: string) => p.startsWith('/entries/libraries'), method: 'GET' })
      .reply(200, { libraries: ['notes'] });
    fetchMock
      .get(KBDB)
      .intercept({ path: (p: string) => p.startsWith('/entries/library-stats'), method: 'GET' })
      .reply(200, { success: true, stats: [{ library: 'notes', card_count: 7 }] });
    fetchMock
      .get(KBDB)
      .intercept({ path: (p: string) => p.startsWith('/records/triplet-stats'), method: 'GET' })
      .reply(200, { success: true, stats: [{ library: 'notes', triplet_count: 108 }] });
    const res = await json('GET', '/portal/admin/libraries', undefined, { Authorization: 'Bearer tok-admin' });
    expect(res.status).toBe(200);
    const data = (await res.json()) as { libraries: { name: string; card_count?: number; triplet_count?: number; auto?: boolean }[] };
    const notes = data.libraries.find((l) => l.name === 'notes');
    expect(notes).toBeDefined();
    expect(notes!.auto).toBe(true);
    expect(notes!.card_count).toBe(7);
    expect(notes!.triplet_count).toBe(108);
  });

  it('庫無內容時 card_count=0 + triplet_count=0（前端顯示「還沒有內容」）', async () => {
    await seedAdminSession();
    mockGetRecord('rec_admin', adminValues());
    mockListByTemplate('portal_library', [
      { record_id: 'rec_lib_empty', values: { name: 'empty', display_name: '空庫', status: 'active', graph_source: 'false' } },
    ]);
    fetchMock
      .get(KBDB)
      .intercept({ path: (p: string) => p.startsWith('/entries/libraries'), method: 'GET' })
      .reply(200, { libraries: [] });
    fetchMock
      .get(KBDB)
      .intercept({ path: (p: string) => p.startsWith('/entries/library-stats'), method: 'GET' })
      .reply(200, { success: true, stats: [] });
    fetchMock
      .get(KBDB)
      .intercept({ path: (p: string) => p.startsWith('/records/triplet-stats'), method: 'GET' })
      .reply(200, { success: true, stats: [] });
    const res = await json('GET', '/portal/admin/libraries', undefined, { Authorization: 'Bearer tok-admin' });
    expect(res.status).toBe(200);
    const data = (await res.json()) as { libraries: { name: string; card_count: number; triplet_count: number }[] };
    const empty = data.libraries.find((l) => l.name === 'empty');
    expect(empty).toBeDefined();
    expect(empty!.card_count).toBe(0);
    expect(empty!.triplet_count).toBe(0);
  });
});

// ═══════════════ t135 庫目錄移除 ═══════════════

describe('DELETE /portal/admin/libraries（t135）', () => {
  it('DELETE /:id — 成功移除已登記庫；KBDB /records/:id DELETE 被呼叫', async () => {
    await seedAdminSession();
    mockGetRecord('rec_admin', adminValues());
    // 成員驗證：list by template 回有該 record
    mockListByTemplate('portal_library', [
      { record_id: 'rec_lib1', values: { name: 'finance', display_name: '財務庫', status: 'active' } },
    ]);
    let deleteCalled = false;
    fetchMock
      .get(KBDB)
      .intercept({ path: '/records/rec_lib1', method: 'DELETE' })
      .reply(200, () => { deleteCalled = true; return { success: true }; });
    const res = await json('DELETE', '/portal/admin/libraries/rec_lib1', undefined, { Authorization: 'Bearer tok-admin' });
    expect(res.status).toBe(200);
    const data = (await res.json()) as { success: boolean; name: string; message: string };
    expect(data.success).toBe(true);
    expect(data.name).toBe('finance');
    expect(deleteCalled).toBe(true);
  });

  it('DELETE /:id — 庫不在目錄 → 404', async () => {
    await seedAdminSession();
    mockGetRecord('rec_admin', adminValues());
    mockListByTemplate('portal_library', []); // 空目錄
    const res = await json('DELETE', '/portal/admin/libraries/rec_lib_x', undefined, { Authorization: 'Bearer tok-admin' });
    expect(res.status).toBe(404);
  });

  it('DELETE /:id — 非 admin → 403', async () => {
    await seedAdminSession('tok-user', 'rec_u1');
    mockGetRecord('rec_u1', userValues());
    const res = await json('DELETE', '/portal/admin/libraries/rec_lib1', undefined, { Authorization: 'Bearer tok-user' });
    expect(res.status).toBe(403);
  });

  it('DELETE /by-name/:name — confirm 符合 → 呼叫 KBDB deprecate-by-library', async () => {
    await seedAdminSession();
    mockGetRecord('rec_admin', adminValues());
    let deprecateCalled = false;
    fetchMock
      .get(KBDB)
      .intercept({ path: '/entries/deprecate-by-library', method: 'PATCH' })
      .reply(200, () => { deprecateCalled = true; return { success: true, deprecated_count: 12 }; });
    const res = await json('DELETE', '/portal/admin/libraries/by-name/kb', { confirm: 'kb' }, { Authorization: 'Bearer tok-admin' });
    expect(res.status).toBe(200);
    const data = (await res.json()) as { success: boolean; deprecated_count: number };
    expect(data.success).toBe(true);
    expect(data.deprecated_count).toBe(12);
    expect(deprecateCalled).toBe(true);
  });

  it('DELETE /by-name/:name — 無 confirm → 400', async () => {
    await seedAdminSession();
    mockGetRecord('rec_admin', adminValues());
    const res = await json('DELETE', '/portal/admin/libraries/by-name/kb', {}, { Authorization: 'Bearer tok-admin' });
    expect(res.status).toBe(400);
  });

  it('DELETE /by-name/:name — confirm 不符 → 400', async () => {
    await seedAdminSession();
    mockGetRecord('rec_admin', adminValues());
    const res = await json('DELETE', '/portal/admin/libraries/by-name/kb', { confirm: 'wrong' }, { Authorization: 'Bearer tok-admin' });
    expect(res.status).toBe(400);
  });

  it('DELETE /by-name/:name — 非 admin → 403', async () => {
    await seedAdminSession('tok-user', 'rec_u1');
    mockGetRecord('rec_u1', userValues());
    const res = await json('DELETE', '/portal/admin/libraries/by-name/kb', { confirm: 'kb' }, { Authorization: 'Bearer tok-user' });
    expect(res.status).toBe(403);
  });
});

// ═══════════════ 6. t176：雲端不再管地端 LLM 設定（取代原 t122／t131 兩組測試）═══════════════
//
// leo 2026-08-03 架構翻案：「地端要用什麼模型就在 daemon 上輸入 API Key 設置，
// 而不是雲端設置後控制地端」。原因是 extractor_config 的 KV key 由 portalTenant() 組出，
// 而 portalTenant 是 **worker 層級**環境變數 ⇒ **全租戶共用一把**：任一處設了 claude，
// 所有人的 daemon 都收到 claude，沒裝 Claude Code 的機器萃取全滅，
// 而 portal 的 Claude 勾選框又恆 disabled（daemon 從未回報 has_claude）⇒ 用戶自己解不開。
//
// 以下是**回歸守衛**：這些端點/欄位若復活，代表又走回「雲端控制地端」的老路。

describe('t176：雲端不再下發／設定地端 LLM', () => {
  const USER_EMAIL = 'daemon@example.com';
  const USER_PW = 'unit-test-pw-1'; // 與 storedHash 配對（外層 beforeAll 計算）
  const USER_RECORD = 'rec_daemon_user';

  /** mock email head lookup（findUserRecordId 走這個路徑）*/
  function mockEmailLookup(email: string, recordId: string | null) {
    const needle = new URLSearchParams({ page_name: email }).toString();
    fetchMock
      .get(KBDB)
      .intercept({
        path: (p: string) => p.startsWith('/entries?') && p.includes(needle) && p.includes(encodeURIComponent(NS)),
        method: 'GET',
      })
      .reply(200, { success: true, entries: recordId ? [{ content: recordId }] : [], count: recordId ? 1 : 0 });
  }

  it('POST /portal/daemon/config 只回連線欄位，**不含任何 LLM 欄位**', async () => {
    mockEmailLookup(USER_EMAIL, USER_RECORD);
    mockGetRecord(USER_RECORD, adminValues({ email: USER_EMAIL, password_hash: storedHash }));

    const res = await json('POST', '/portal/daemon/config', { email: USER_EMAIL, password: USER_PW });
    expect(res.status).toBe(200);
    const d = (await res.json()) as { config: Record<string, unknown> };

    // 連線欄位照舊（daemon 靠它上線）
    expect(d.config.cypher_url).toBeTruthy();
    expect(d.config.namespace).toBeTruthy();
    expect(d.config.library).toBe('kb');

    // LLM 欄位一律不下發（t176 核心）
    expect(d.config).not.toHaveProperty('extractor');
    expect(d.config).not.toHaveProperty('gemini_api_key');
    expect(d.config).not.toHaveProperty('llm_model');
  });

  // 註：route 不存在 ⇒ 在認證之前就 404，因此不需要（也不能）預先掛 record mock，
  // 否則 afterEach 的 assertNoPendingInterceptors 會因「mock 沒被用到」而失敗。
  it('POST /portal/admin/extractor 已移除（雲端不再有指定地端引擎的入口）', async () => {
    const res = await json('POST', '/portal/admin/extractor', { engine: 'claude' }, { Authorization: 'Bearer tok-ex' });
    expect(res.status).toBe(404);
  });

  it('POST /portal/daemon/report-capabilities 已移除（has_claude 回報鏈整條退役）', async () => {
    const res = await json('POST', '/portal/daemon/report-capabilities', {
      email: USER_EMAIL, password: USER_PW, has_claude: true,
    });
    expect(res.status).toBe(404);
  });
});

// ═══════════════ 7. /portal HTML 殼（P4 admin 頁後紅線不回退）═══════════════

describe('GET /portal（P4 admin 頁 HTML 殼）', () => {
  it('admin view 存在；仍零租戶字串、零 /kbdb/、零 X-Arcrun-API-Key、零 Mira；無 kb 種子、無登記到目錄', async () => {
    const res = await SELF.fetch('http://localhost/portal');
    expect(res.status).toBe(200);
    const html = await res.text();
    expect(html).toContain('v-admin'); // P4 管理頁 view
    expect(html).toContain('/portal/admin/users'); // 帳號管理走 admin API
    expect(html).toContain('/portal/admin/libraries'); // 庫目錄管理
    // P3 紅線（design §3.3）在加了 admin UI 後不得回退
    expect(html).not.toMatch(/['"]leo['"]/);
    expect(html).not.toContain('/kbdb/');
    expect(html).not.toContain('X-Arcrun-API-Key');
    expect(html).not.toContain('Mira');
    // t97a：bootstrap 後不再預埋 kb 庫
    expect(html).not.toContain('"name": "kb"');
    expect(html).not.toContain("name: 'kb'");
    // t114：無「登記到目錄」按鈕
    expect(html).not.toContain('lib-adopt');
    expect(html).not.toContain('登記到目錄');
    // t131：合併 AI 設定（舊兩區塊已移除）
    expect(html).toContain('st-ai-panel');
    expect(html).toContain('st-ai-key');
    expect(html).toContain('st-ai-use-claude');
    expect(html).not.toContain('st-extractor-panel');
    expect(html).not.toContain('st-key-save'); // 舊 chat-key 存檔鈕已移除
  });
});

// t131/t122 測試已隨 main 的 t176（刪除雲端下發 LLM 設定）一併移除；
// 此處只保留 t181（daemon 走 Workers AI）的守衛。

describe('POST /portal/daemon/extract（t181：Workers AI 萃卡，免金鑰）', () => {
  // 認證＝X-Arcrun-API-Key（＝namespace，wrangler.test.toml CONSOLE_TENANT=leo），
  // **不是帳密**：daemon 密碼不落地（連線精靈用完即丟），背景萃取拿不到密碼。
  const KEY = { 'X-Arcrun-API-Key': 'leo' };

  it('沒帶 API Key → 401', async () => {
    const res = await json('POST', '/portal/daemon/extract', { page_name: 'x', text: 'y' });
    expect(res.status).toBe(401);
  });

  // 🔴 t189：這則原本是「API Key 錯 → 401（租戶隔離）」，**是錯的，而且害我看到假綠**。
  //
  //	它假設「daemon 的 api_key ＝ 實例的 CONSOLE_TENANT」，但實測不成立：
  //	  geek6688：tenant=ckxt8yr9、daemon api_key=yuga3bse ⇒ 真用戶**永遠 401**、萃不了
  //	  youlin  ：兩者碰巧相同 ⇒ 我這邊測起來都對
  //	舊測試只證明「符合我的假設」，不證明「假設是對的」——
  //	**把錯誤假設寫成測試，就是把假綠焊死。**
  //
  //	翻轉成守衛：**key 與 tenant 不同也要能萃**（這正是 leo 撞到的情境）。
  //	若哪天有人又加回等值比對，這則會紅。
  it('key 與實例 tenant 不同也要能用（t189：多帳號 daemon 的常態）', async () => {
    const res = await json('POST', '/portal/daemon/extract',
      { page_name: 'x', text: 'y' }, { 'X-Arcrun-API-Key': 'another-tenant-key' });
    expect(res.status).not.toBe(401);
  });

  it('缺 page_name 或 text → 400（不打 AI、不假裝成功）', async () => {
    const res = await json('POST', '/portal/daemon/extract', {}, KEY);
    expect(res.status).toBe(400);
    const d = (await res.json()) as { error?: string };
    expect(String(d.error)).toContain('page_name');
  });

  // 🔴 回歸守衛：這條路**不得**要求任何 Gemini／API 金鑰——免金鑰正是它存在的理由。
  // 若哪天有人把它改回打 Google，錯誤訊息會出現 credential/gemini_api_key ⇒ 這則會紅。
  it('錯誤訊息不得要求任何金鑰（免金鑰是本端點存在的理由）', async () => {
    const res = await json('POST', '/portal/daemon/extract', {}, KEY);
    const raw = await res.text();
    expect(raw).not.toContain('gemini_api_key');
    expect(raw).not.toContain('credential');
  });
});
