/**
 * portal-auth P3 測試（design §1/§3.3/§3.4/§5/§6，Gitea #24/#25）
 *
 * 覆蓋（=tasks.md P3 測試項＋#24 驗收 3 的 server-side 證明）：
 *   1. /portal HTML 殼：200、brand、**零租戶字串/零 X-Arcrun-API-Key/零 Mira 字樣**
 *   2. /portal/data/search enforce：server 注入 owner_id＋library；caller 自帶
 *      owner_id/library 參數被靜默覆蓋（filter 繞不過的機械證明）；["*"]＝不注 library；
 *      空集合＝誠實空結果不打 KBDB
 *   3. /portal/data/entries/:id 逐筆驗庫：越庫 404、跨租戶 404、不存在 404（同一句，
 *      不洩存在性）、NULL library→general fallback
 *   4. graph D-4 粗閘：無來源庫權限 403（不打 plugin）；["*"]／有權 → 轉發
 *   5. workflows D-8：非 admin 403；admin 唯讀 list＋最近執行、回應無 webhook_url；
 *      workflowsVisible 單元（admin/all/off/壞值）
 *   6. /portal/session 能力欄位：graph_allowed / workflows_visible
 *
 * KBDB／graph-plugin 都打 fetchMock 假 host（wrangler.test.toml KBDB_BASE_URL=
 * https://kbdb.test、KBDB_GRAPH_URL=https://graph.test）＋disableNetConnect——絕不外連。
 */
import { SELF, env, fetchMock } from 'cloudflare:test';
import { beforeAll, afterEach, describe, it, expect } from 'vitest';
import { workflowsVisible } from '../src/routes/portal';
import { entryLibrary, sanitizeUploadFilename, filterDeprecatedEntries, mapGraphWorkflowOutput, normalizeCjkQuery, findBestNodeMatch, dedupeSourcesByPage } from '../src/routes/portal-data';
import type { Bindings } from '../src/types';

const KBDB = 'https://kbdb.test';
const GRAPH = 'https://graph.test';
const TENANT = 'leo'; // wrangler.test.toml CONSOLE_TENANT（只在 server 側；下面驗它不出現在前端）

beforeAll(() => {
  fetchMock.activate();
  fetchMock.disableNetConnect();
});
afterEach(() => fetchMock.assertNoPendingInterceptors());

function get(path: string, headers: Record<string, string> = {}) {
  return SELF.fetch(`http://localhost${path}`, { headers });
}

async function seedSession(token: string, recordId: string) {
  await env.SESSIONS_KV.put(`portal_sess:${token}`, JSON.stringify({ record_id: recordId }));
}

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 mockLibraryList(records: { record_id: string; values: Record<string, string> }[]) {
  fetchMock
    .get(KBDB)
    .intercept({ path: (p: string) => p.startsWith('/records/by-template/portal_library'), method: 'GET' })
    .reply(200, { success: true, records: records.map((r) => ({ ...r, template_id: 'tpl_pl' })), count: records.length });
}

function userValues(overrides: Record<string, string> = {}): Record<string, string> {
  return {
    email: 'user@example.com',
    display_name: '測試同仁',
    status: 'active',
    role: 'user',
    password_hash: 'pbkdf2-sha256$600000$AA$BB',
    libraries: '["finance"]',
    created_at: '2026-07-14T00:00:00.000Z',
    updated_at: '2026-07-14T00:00:00.000Z',
    ...overrides,
  };
}

/** 攔 KBDB /entries/search 並回收實際轉發的 query（enforce 的機械證據）。 */
function captureSearch(reply: unknown = { success: true, entries: [], count: 0, mode: 'keyword' }): { url: () => string } {
  let captured = '';
  fetchMock
    .get(KBDB)
    .intercept({
      path: (p: string) => {
        if (!p.startsWith('/entries/search?')) return false;
        captured = p;
        return true;
      },
      method: 'GET',
    })
    .reply(200, reply as Record<string, unknown>);
  return { url: () => captured };
}

// ═══════════════ 1. /portal HTML 殼 ═══════════════

describe('GET /portal（HTML 殼）', () => {
  it('200；brand 出現；**前端零租戶字串、零 X-Arcrun-API-Key、零 Mira**', async () => {
    const res = await get('/portal');
    expect(res.status).toBe(200);
    const html = await res.text();
    expect(html).toContain('Arcrun Portal'); // CONSOLE_BRAND 未設 → Arcrun（引擎共用件不寫死產品名）
    expect(html).toContain('/portal/data/search'); // 資料只走 enforce 面
    // design §3.3 關鍵差異的機械斷言：前端不持租戶字串、不打 /kbdb/*
    expect(html).not.toContain('X-Arcrun-API-Key');
    expect(html).not.toMatch(/['"]leo['"]/); // 租戶字串值不得出現在頁面
    expect(html).not.toContain('/kbdb/'); // 不直打 kbdb proxy（那要 API key＝租戶字串）
    expect(html).not.toContain('Mira'); // 零 Mira 字樣（tasks.md P3）
    expect(html).not.toContain('CONSOLE_TENANT');
  });
});

// ═══════════════ 2. /portal/data/search enforce ═══════════════

describe('GET /portal/data/search', () => {
  it('未登入 → 401，不碰 KBDB', async () => {
    const res = await get('/portal/data/search?q=hello');
    expect(res.status).toBe(401);
  });

  it('server 注入 owner_id＋library；caller 自帶 owner_id/library 被靜默覆蓋（繞不過）', async () => {
    await seedSession('tok-s1', 'rec_1');
    mockGetRecord('rec_1', userValues()); // libraries=["finance"]
    const cap = captureSearch();
    // 攻擊嘗試：自帶 library=hr + owner_id=evil → 應完全被 server 值取代
    const res = await get('/portal/data/search?q=報告&library=hr&owner_id=evil', {
      Authorization: 'Bearer tok-s1',
    });
    expect(res.status).toBe(200);
    const sent = new URLSearchParams(cap.url().split('?')[1]);
    expect(sent.get('owner_id')).toBe(TENANT); // server 注入的租戶
    expect(sent.get('library')).toBe('finance'); // server 注入的用戶庫集合
    expect(cap.url()).not.toContain('hr'); // caller 的越權參數完全沒被轉發
    expect(cap.url()).not.toContain('evil');
  });

  it('多庫用戶 → library=逗號集合；mode=semantic 透傳', async () => {
    await seedSession('tok-s2', 'rec_2');
    mockGetRecord('rec_2', userValues({ libraries: '["general","finance"]' }));
    const cap = captureSearch({ success: true, entries: [], count: 0, mode: 'semantic' });
    const res = await get('/portal/data/search?q=q1&mode=semantic', { Authorization: 'Bearer tok-s2' });
    expect(res.status).toBe(200);
    const sent = new URLSearchParams(cap.url().split('?')[1]);
    expect(sent.get('library')).toBe('general,finance');
    expect(sent.get('mode')).toBe('semantic');
  });

  it('["*"]（全庫）→ 只注 owner_id、不注 library（design §3.3）', async () => {
    await seedSession('tok-s3', 'rec_3');
    mockGetRecord('rec_3', userValues({ libraries: '["*"]', role: 'admin' }));
    const cap = captureSearch();
    const res = await get('/portal/data/search?q=q2', { Authorization: 'Bearer tok-s3' });
    expect(res.status).toBe(200);
    const sent = new URLSearchParams(cap.url().split('?')[1]);
    expect(sent.get('owner_id')).toBe(TENANT);
    expect(sent.has('library')).toBe(false);
  });

  it('庫集合為空 → 誠實空結果，不打 KBDB search', async () => {
    await seedSession('tok-s4', 'rec_4');
    mockGetRecord('rec_4', userValues({ libraries: '[]' }));
    const res = await get('/portal/data/search?q=q3', { Authorization: 'Bearer tok-s4' });
    expect(res.status).toBe(200);
    const data = (await res.json()) as { entries: unknown[]; note?: string };
    expect(data.entries).toEqual([]);
    expect(data.note).toContain('尚未被授權'); // 無 pending interceptor＝真沒打 KBDB
  });
});

// ═══════════════ 3. /portal/data/entries/:id 逐筆驗庫 ═══════════════

function mockGetEntry(id: string, entry: Record<string, unknown> | null) {
  fetchMock
    .get(KBDB)
    .intercept({ path: `/entries/${id}`, method: 'GET' })
    .reply(entry ? 200 : 404, entry ? { success: true, entry } : { success: false, error: 'not found' });
}

describe('GET /portal/data/entries/:id（逐筆驗庫）', () => {
  it('越庫 id 直讀（hr entry、用戶只有 finance）→ 404', async () => {
    await seedSession('tok-e1', 'rec_1');
    mockGetRecord('rec_1', userValues());
    mockGetEntry('e_hr', { id: 'e_hr', owner_id: TENANT, metadata_json: '{"library":"hr"}', content: '機密' });
    const res = await get('/portal/data/entries/e_hr', { Authorization: 'Bearer tok-e1' });
    expect(res.status).toBe(404);
    const data = (await res.json()) as { error: string };
    expect(data.error).toBe('找不到這筆資料'); // 與不存在同一句（不洩存在性）
    expect(JSON.stringify(data)).not.toContain('hr'); // 不洩庫名
  });

  it('有權庫（finance）→ 200 回 entry', async () => {
    await seedSession('tok-e2', 'rec_1');
    mockGetRecord('rec_1', userValues());
    mockGetEntry('e_fin', { id: 'e_fin', owner_id: TENANT, metadata_json: '{"library":"finance","source":"logseq://x.md"}', content: '財務' });
    const res = await get('/portal/data/entries/e_fin', { Authorization: 'Bearer tok-e2' });
    expect(res.status).toBe(200);
    const data = (await res.json()) as { entry: { id: string } };
    expect(data.entry.id).toBe('e_fin');
  });

  it('跨租戶 entry（owner_id 不是本實例租戶）→ 404 同一句', async () => {
    await seedSession('tok-e3', 'rec_1');
    mockGetRecord('rec_1', userValues({ libraries: '["*"]' })); // 就算全庫也擋跨租戶
    mockGetEntry('e_other', { id: 'e_other', owner_id: 'other-tenant', metadata_json: '{"library":"finance"}' });
    const res = await get('/portal/data/entries/e_other', { Authorization: 'Bearer tok-e3' });
    expect(res.status).toBe(404);
    expect(((await res.json()) as { error: string }).error).toBe('找不到這筆資料');
  });

  it('不存在的 id → 404 同一句', async () => {
    await seedSession('tok-e4', 'rec_1');
    mockGetRecord('rec_1', userValues());
    mockGetEntry('e_ghost', null);
    const res = await get('/portal/data/entries/e_ghost', { Authorization: 'Bearer tok-e4' });
    expect(res.status).toBe(404);
    expect(((await res.json()) as { error: string }).error).toBe('找不到這筆資料');
  });

  it('未標記 library（NULL metadata）→ 歸 general：有 general 者 200、無者 404', async () => {
    await seedSession('tok-e5', 'rec_5');
    mockGetRecord('rec_5', userValues({ libraries: '["general"]' }));
    mockGetEntry('e_old', { id: 'e_old', owner_id: TENANT, metadata_json: null, content: '舊資料' });
    const ok = await get('/portal/data/entries/e_old', { Authorization: 'Bearer tok-e5' });
    expect(ok.status).toBe(200);

    await seedSession('tok-e6', 'rec_6');
    mockGetRecord('rec_6', userValues({ libraries: '["finance"]' })); // 沒 general
    mockGetEntry('e_old', { id: 'e_old', owner_id: TENANT, metadata_json: null, content: '舊資料' });
    const no = await get('/portal/data/entries/e_old', { Authorization: 'Bearer tok-e6' });
    expect(no.status).toBe(404);
  });

  it('entryLibrary 單元：壞 metadata/缺欄位 → general；有 library → 原值', () => {
    expect(entryLibrary({ metadata_json: null })).toBe('general');
    expect(entryLibrary({ metadata_json: 'not-json{{' })).toBe('general');
    expect(entryLibrary({ metadata_json: '{"source":"x"}' })).toBe('general');
    expect(entryLibrary({ metadata_json: '{"library":""}' })).toBe('general');
    expect(entryLibrary({ metadata_json: '{"library":"hr"}' })).toBe('hr');
  });
});

// ═══════════════ 3b. 授權的 AI（arcrun-mcp）走的資料面 ═══════════════
//
// leo 2026-08-12：「AI 透過輸入帳密的 MCP 查詢表示是授權的 AI，可以查到主人允許查的任何東西。」
// ⇒ 這幾支端點與人類走的 search/entries 是同一道閘：同一個 session、同一份庫權限、
//   同樣「呼叫端自帶 owner_id 一律不生效」、同樣「越權與不存在同一句 404」。

describe('藏書地圖 /portal/data/map（MCP 走的那條）', () => {
  it('只回這個帳號有權限的庫；全館其他庫不出現在回應裡', async () => {
    await seedSession('tok-m1', 'rec_1');
    mockGetRecord('rec_1', userValues({ libraries: '["finance"]' }));
    fetchMock
      .get(KBDB)
      .intercept({ path: (p: string) => p.startsWith('/map?'), method: 'GET' })
      .reply(200, {
        success: true,
        libraries: [
          { library: 'finance', narrative: '財務', top_entities: [], triplet_count: 3 },
          { library: 'hr', narrative: '人資', top_entities: [], triplet_count: 9 },
        ],
        count: 2,
      });
    const res = await get('/portal/data/map', { Authorization: 'Bearer tok-m1' });
    expect(res.status).toBe(200);
    const data = (await res.json()) as { libraries: { library: string }[]; count: number };
    expect(data.libraries.map((l) => l.library)).toEqual(['finance']);
    expect(data.count).toBe(1);
  });

  it('["*"] 全庫 → 全部庫都回', async () => {
    await seedSession('tok-m2', 'rec_2');
    mockGetRecord('rec_2', userValues({ libraries: '["*"]' }));
    fetchMock
      .get(KBDB)
      .intercept({ path: (p: string) => p.startsWith('/map?'), method: 'GET' })
      .reply(200, {
        success: true,
        libraries: [
          { library: 'finance', narrative: '', top_entities: [], triplet_count: 3 },
          { library: 'hr', narrative: '', top_entities: [], triplet_count: 9 },
        ],
        count: 2,
      });
    const res = await get('/portal/data/map', { Authorization: 'Bearer tok-m2' });
    const data = (await res.json()) as { libraries: { library: string }[] };
    expect(data.libraries.map((l) => l.library)).toEqual(['finance', 'hr']);
  });

  it('庫集合為空 → 誠實空結果＋說明，不打 KBDB', async () => {
    await seedSession('tok-m3', 'rec_3');
    mockGetRecord('rec_3', userValues({ libraries: '[]' }));
    const res = await get('/portal/data/map', { Authorization: 'Bearer tok-m3' });
    expect(res.status).toBe(200);
    const data = (await res.json()) as { count: number; note?: string };
    expect(data.count).toBe(0);
    expect(data.note).toContain('尚未被授權');
  });

  it('單庫詳圖：無權該庫 → 404 同一句（不打 KBDB，不洩該庫存不存在）', async () => {
    await seedSession('tok-m4', 'rec_4');
    mockGetRecord('rec_4', userValues({ libraries: '["finance"]' }));
    const res = await get('/portal/data/map/hr', { Authorization: 'Bearer tok-m4' });
    expect(res.status).toBe(404);
    expect(((await res.json()) as { error: string }).error).toBe('找不到這筆資料');
  });

  it('單庫詳圖：有權該庫 → 200 轉發', async () => {
    await seedSession('tok-m5', 'rec_5');
    mockGetRecord('rec_5', userValues({ libraries: '["finance"]' }));
    fetchMock
      .get(KBDB)
      .intercept({ path: (p: string) => p.startsWith('/map/finance'), method: 'GET' })
      .reply(200, { success: true, map: { library: 'finance', triplet_count: 3 } });
    const res = await get('/portal/data/map/finance', { Authorization: 'Bearer tok-m5' });
    expect(res.status).toBe(200);
  });

  it('未登入 → 401', async () => {
    expect((await get('/portal/data/map')).status).toBe(401);
  });
});

describe('結構化資料 /portal/data/records、/portal/data/templates（MCP 走的那條）', () => {
  it('by-template：server 注入 owner_id；caller 自帶的被靜默覆蓋（繞不過）', async () => {
    await seedSession('tok-r1', 'rec_1');
    mockGetRecord('rec_1', userValues({ libraries: '["*"]' }));
    let captured = '';
    fetchMock
      .get(KBDB)
      .intercept({
        path: (p: string) => {
          if (!p.startsWith('/records/by-template/contact')) return false;
          captured = p;
          return true;
        },
        method: 'GET',
      })
      .reply(200, { success: true, records: [], count: 0 });
    const res = await get('/portal/data/records/by-template/contact?owner_id=someone-else', {
      Authorization: 'Bearer tok-r1',
    });
    expect(res.status).toBe(200);
    expect(new URL(`http://x${captured}`).searchParams.get('owner_id')).toBe(TENANT);
  });

  it('by-template：有標 library 的 record 越庫的被濾掉；沒標 library 的照回', async () => {
    await seedSession('tok-r2', 'rec_2');
    mockGetRecord('rec_2', userValues({ libraries: '["finance"]' }));
    fetchMock
      .get(KBDB)
      .intercept({ path: (p: string) => p.startsWith('/records/by-template/triplet'), method: 'GET' })
      .reply(200, {
        success: true,
        records: [
          { record_id: 'r1', owner_id: TENANT, values: { library: 'finance', subject: 'A' } },
          { record_id: 'r2', owner_id: TENANT, values: { library: 'hr', subject: 'B' } },
          { record_id: 'r3', owner_id: TENANT, values: { subject: 'C' } }, // 沒標庫＝結構化資料列
        ],
        count: 3,
      });
    const res = await get('/portal/data/records/by-template/triplet', { Authorization: 'Bearer tok-r2' });
    const data = (await res.json()) as { records: { record_id: string }[] };
    expect(data.records.map((r) => r.record_id)).toEqual(['r1', 'r3']);
  });

  it('單筆：別的租戶的 record → 404 同一句（就算全庫權限也擋）', async () => {
    await seedSession('tok-r3', 'rec_3');
    mockGetRecord('rec_3', userValues({ libraries: '["*"]' }));
    fetchMock
      .get(KBDB)
      .intercept({ path: '/records/r_other', method: 'GET' })
      .reply(200, { success: true, record: { record_id: 'r_other', owner_id: 'other-tenant', values: {} } });
    const res = await get('/portal/data/records/r_other', { Authorization: 'Bearer tok-r3' });
    expect(res.status).toBe(404);
    expect(((await res.json()) as { error: string }).error).toBe('找不到這筆資料');
  });

  it('單筆：越庫的 record → 404 同一句；有權的 → 200', async () => {
    await seedSession('tok-r4', 'rec_4');
    mockGetRecord('rec_4', userValues({ libraries: '["finance"]' }));
    fetchMock
      .get(KBDB)
      .intercept({ path: '/records/r_hr', method: 'GET' })
      .reply(200, { success: true, record: { record_id: 'r_hr', owner_id: TENANT, values: { library: 'hr' } } });
    expect((await get('/portal/data/records/r_hr', { Authorization: 'Bearer tok-r4' })).status).toBe(404);

    await seedSession('tok-r5', 'rec_5');
    mockGetRecord('rec_5', userValues({ libraries: '["finance"]' }));
    fetchMock
      .get(KBDB)
      .intercept({ path: '/records/r_fin', method: 'GET' })
      .reply(200, { success: true, record: { record_id: 'r_fin', owner_id: TENANT, values: { library: 'finance' } } });
    expect((await get('/portal/data/records/r_fin', { Authorization: 'Bearer tok-r5' })).status).toBe(200);
  });

  it('寫入：owner_id 由 server 定死，呼叫端塞的不算', async () => {
    await seedSession('tok-r6', 'rec_6');
    mockGetRecord('rec_6', userValues({ libraries: '["*"]' }));
    let body: Record<string, unknown> = {};
    fetchMock
      .get(KBDB)
      .intercept({
        path: '/records',
        method: 'POST',
        body: (b: string) => {
          body = JSON.parse(b) as Record<string, unknown>;
          return true;
        },
      })
      .reply(200, { success: true, record: { record_id: 'r_new' } });
    const res = await SELF.fetch('http://localhost/portal/data/records', {
      method: 'POST',
      headers: { Authorization: 'Bearer tok-r6', 'Content-Type': 'application/json' },
      body: JSON.stringify({ template: 'contact', values: { name: 'Leo' }, owner_id: 'someone-else' }),
    });
    expect(res.status).toBe(200);
    expect(body.owner_id).toBe(TENANT);
  });

  it('寫入越庫 → 403（明確拒絕，庫名是呼叫端自己指定的，沒有存在性可洩）', async () => {
    await seedSession('tok-r7', 'rec_7');
    mockGetRecord('rec_7', userValues({ libraries: '["finance"]' }));
    const res = await SELF.fetch('http://localhost/portal/data/records', {
      method: 'POST',
      headers: { Authorization: 'Bearer tok-r7', 'Content-Type': 'application/json' },
      body: JSON.stringify({ template: 'note', values: { library: 'hr', body: 'x' } }),
    });
    expect(res.status).toBe(403);
  });

  it('templates 全域共享（schema 非內容）：登入即可列', async () => {
    await seedSession('tok-t1', 'rec_t1');
    mockGetRecord('rec_t1', userValues({ libraries: '["finance"]' }));
    fetchMock
      .get(KBDB)
      .intercept({ path: '/templates', method: 'GET' })
      .reply(200, { success: true, templates: [{ id: 'tpl1', name: 'contact' }], count: 1 });
    const res = await get('/portal/data/templates', { Authorization: 'Bearer tok-t1' });
    expect(res.status).toBe(200);
    expect(((await res.json()) as { count: number }).count).toBe(1);
  });

  it('未登入 → 401（records / templates 都是）', async () => {
    expect((await get('/portal/data/templates')).status).toBe(401);
    expect((await get('/portal/data/records/by-template/contact')).status).toBe(401);
    expect((await get('/portal/data/records/r1')).status).toBe(401);
  });
});

// ═══════════════ 4. graph D-4 粗閘 ═══════════════

describe('GET /portal/data/graph/neighbors/:name（D-4 粗閘）', () => {
  it('無 graph 來源庫權限（來源庫預設 general、用戶只有 finance）→ 403，不打 plugin', async () => {
    await seedSession('tok-g1', 'rec_1');
    mockGetRecord('rec_1', userValues()); // finance only
    mockLibraryList([]); // 沒有任何庫標 graph_source → 來源預設 ['general']
    const res = await get('/portal/data/graph/neighbors/某節點', { Authorization: 'Bearer tok-g1' });
    expect(res.status).toBe(403);
    // 無 pending interceptor（afterEach 驗）＝graph plugin 完全沒被打
  });

  it('["*"] 全庫 → 放行並轉發 plugin（不需查庫目錄）', async () => {
    await seedSession('tok-g2', 'rec_2');
    mockGetRecord('rec_2', userValues({ libraries: '["*"]', role: 'admin' }));
    fetchMock
      .get(GRAPH)
      .intercept({ path: (p: string) => p.startsWith('/graph/neighbors/'), method: 'GET' })
      .reply(200, { node: 'n', edges: [], neighbors: [], edgeCount: 0, neighborCount: 0 });
    const res = await get('/portal/data/graph/neighbors/n', { Authorization: 'Bearer tok-g2' });
    expect(res.status).toBe(200);
  });

  it('庫目錄標 finance 為 graph_source → finance 用戶放行', async () => {
    await seedSession('tok-g3', 'rec_1');
    mockGetRecord('rec_1', userValues()); // finance
    mockLibraryList([
      { record_id: 'lib_fin', values: { name: 'finance', status: 'active', graph_source: 'true' } },
    ]);
    fetchMock
      .get(GRAPH)
      .intercept({ path: (p: string) => p.startsWith('/graph/neighbors/'), method: 'GET' })
      .reply(200, { node: 'n', edges: [], neighbors: [] });
    const res = await get('/portal/data/graph/neighbors/n', { Authorization: 'Bearer tok-g3' });
    expect(res.status).toBe(200);
  });

  it('停用的 graph_source 庫不算來源（disabled 排除 → 回到預設 general → finance 用戶 403）', async () => {
    await seedSession('tok-g4', 'rec_1');
    mockGetRecord('rec_1', userValues());
    mockLibraryList([
      { record_id: 'lib_fin', values: { name: 'finance', status: 'disabled', graph_source: 'true' } },
    ]);
    const res = await get('/portal/data/graph/neighbors/n', { Authorization: 'Bearer tok-g4' });
    expect(res.status).toBe(403);
  });
});

// ═══════════════ 5. workflows D-8 ═══════════════

describe('GET /portal/data/workflows（D-8：admin 唯讀）', () => {
  it('非 admin（預設 PORTAL_SHOW_WORKFLOWS=admin）→ 403', async () => {
    await seedSession('tok-w1', 'rec_1');
    mockGetRecord('rec_1', userValues({ role: 'user' }));
    const res = await get('/portal/data/workflows', { Authorization: 'Bearer tok-w1' });
    expect(res.status).toBe(403);
  });

  it('admin → 200 唯讀 list＋最近執行；**回應無 webhook_url／trigger 把手**', async () => {
    await seedSession('tok-w2', 'rec_a');
    mockGetRecord('rec_a', userValues({ role: 'admin', libraries: '["*"]' }));
    await env.WEBHOOKS.put(
      `${TENANT}:wf:daily_report`,
      JSON.stringify({ description: '每日彙整', created_at: '2026-07-14T00:00:00Z', cron_expr: '0 9 * * *' }),
    );
    // KV 額度事故修復（2026-08-07）：last_execution 資料源改打 KBDB GET /execution-log/latest
    // （KBDB＝API-as-Wall，本檔一律 fetchMock 攔截，不碰任何 D1）。
    fetchMock
      .get(KBDB)
      .intercept({ path: (p: string) => p.startsWith('/execution-log/latest?'), method: 'GET' })
      .reply(200, { success: true, execution: { verdict: 'success', recorded_at: 1783500000 } });
    const res = await get('/portal/data/workflows', { Authorization: 'Bearer tok-w2' });
    expect(res.status).toBe(200);
    const data = (await res.json()) as {
      workflows: { name: string; description: string; last_execution: { verdict?: string; timestamp: string } | null }[];
      read_only: boolean;
    };
    expect(data.read_only).toBe(true);
    const wf = data.workflows.find((w) => w.name === 'daily_report');
    expect(wf).toBeTruthy();
    expect(wf!.description).toBe('每日彙整');
    expect(wf!.last_execution?.verdict).toBe('success');
    expect(JSON.stringify(data)).not.toContain('webhook_url');
    expect(JSON.stringify(data)).not.toContain('/trigger');
    // 清場（KV 是 suite 共用實例，避免污染其他測試）
    await env.WEBHOOKS.delete(`${TENANT}:wf:daily_report`);
  });

  it('workflowsVisible 單元：admin（預設/壞值）/ all / off', () => {
    const mk = (v?: string) => ({ PORTAL_SHOW_WORKFLOWS: v }) as unknown as Bindings;
    expect(workflowsVisible(mk(undefined), 'admin')).toBe(true);
    expect(workflowsVisible(mk(undefined), 'user')).toBe(false);
    expect(workflowsVisible(mk('all'), 'user')).toBe(true);
    expect(workflowsVisible(mk('off'), 'admin')).toBe(false);
    expect(workflowsVisible(mk('typo!!'), 'user')).toBe(false); // 壞值退回 admin-only，不意外全開
    expect(workflowsVisible(mk('typo!!'), 'admin')).toBe(true);
  });
});

// ═══════════════ 6. /portal/session 能力欄位 ═══════════════

describe('GET /portal/session（P3 能力欄位）', () => {
  it('一般 user（finance，無 graph 來源權限）→ graph_allowed=false、workflows_visible=false；仍無租戶字串', async () => {
    await seedSession('tok-p1', 'rec_1');
    mockGetRecord('rec_1', userValues());
    mockLibraryList([]);
    const res = await get('/portal/session', { Authorization: 'Bearer tok-p1' });
    expect(res.status).toBe(200);
    const data = (await res.json()) as Record<string, unknown>;
    expect(data.graph_allowed).toBe(false);
    expect(data.workflows_visible).toBe(false);
    expect('tenant' in data).toBe(false);
    expect(JSON.stringify(data)).not.toContain('"leo"');
  });

  it('admin ["*"] → graph_allowed=true（免查庫目錄）、workflows_visible=true', async () => {
    await seedSession('tok-p2', 'rec_a');
    mockGetRecord('rec_a', userValues({ role: 'admin', libraries: '["*"]' }));
    const res = await get('/portal/session', { Authorization: 'Bearer tok-p2' });
    expect(res.status).toBe(200);
    const data = (await res.json()) as Record<string, unknown>;
    expect(data.graph_allowed).toBe(true);
    expect(data.workflows_visible).toBe(true);
    // portal-demo-suite：測試環境未設 upload bindings → upload_enabled=false（Mira 零影響預設）
    expect(data.upload_enabled).toBe(false);
  });
});

// ═══════════════ 7. portal-demo-suite 純函式 ═══════════════

describe('sanitizeUploadFilename（上傳檔名驗證）', () => {
  it('去路徑分隔（擋穿越）、.txt 改 .md、無副檔名補 .md', () => {
    expect(sanitizeUploadFilename('notes.md')).toBe('notes.md');
    expect(sanitizeUploadFilename('memo.txt')).toBe('memo.md');
    expect(sanitizeUploadFilename('README')).toBe('README.md');
    expect(sanitizeUploadFilename('../../etc/passwd')).toBe('passwd.md'); // 只取最後一段，穿越失效
    expect(sanitizeUploadFilename('a\\b\\c.md')).toBe('c.md');
    expect(sanitizeUploadFilename('中文筆記.txt')).toBe('中文筆記.md');
  });
  it('空名/純路徑/隱藏檔/超過 100 字/非字串 → null', () => {
    expect(sanitizeUploadFilename('')).toBe(null);
    expect(sanitizeUploadFilename('   ')).toBe(null);
    expect(sanitizeUploadFilename('docs/')).toBe(null);
    expect(sanitizeUploadFilename('.env')).toBe(null);
    expect(sanitizeUploadFilename('a'.repeat(120) + '.md')).toBe(null);
    expect(sanitizeUploadFilename(42)).toBe(null);
    expect(sanitizeUploadFilename(undefined)).toBe(null);
  });
});

describe('filterDeprecatedEntries（Arcrun#46 搜尋殘影治標）', () => {
  it('濾 status=deprecated 與「（舊管線產物」開頭；metadata parse 失敗保留', () => {
    const keepNormal = { metadata_json: '{"library":"general"}', content: '正常內容' };
    const keepBadMeta = { metadata_json: 'not-json{{', content: '壞 metadata 不誤殺' };
    const keepNullMeta = { metadata_json: null, content: '無 metadata' };
    const dropByStatus = { metadata_json: '{"status":"deprecated"}', content: '看起來正常但已標廢' };
    const dropByContent = { metadata_json: '{}', content: '（舊管線產物）殘影條目' };
    const out = filterDeprecatedEntries([keepNormal, dropByStatus, keepBadMeta, dropByContent, keepNullMeta]);
    expect(out).toEqual([keepNormal, keepBadMeta, keepNullMeta]);
  });
  it('空陣列 → 空陣列', () => {
    expect(filterDeprecatedEntries([])).toEqual([]);
  });
  it('濾內部型別 value/workflow（無標題雜項列；leo 2026-07-18 客戶測試回饋）；block/wiki 保留', () => {
    const keepBlock = { entry_type: 'block', metadata_json: '{}', content: '正常 block' };
    const keepWiki = { entry_type: 'wiki', metadata_json: '{}', content: '精耕頁' };
    const keepNoType = { metadata_json: '{}', content: '無 entry_type 不誤殺' };
    const dropValue = { entry_type: 'value', metadata_json: '{}', content: '特休假' };
    const dropWorkflow = { entry_type: 'workflow', metadata_json: '{}', content: '同步問答：…' };
    const out = filterDeprecatedEntries([keepBlock, dropValue, keepWiki, dropWorkflow, keepNoType]);
    expect(out).toEqual([keepBlock, keepWiki, keepNoType]);
  });
});

describe('mapGraphWorkflowOutput（#57 workflow 輸出 → plugin 形狀）', () => {
  it('本體有 neighbors → 直取；count 重算不信自報', () => {
    const out = mapGraphWorkflowOutput({ neighbors: ['a', 'b'], edges: [{ subject: 'a', predicate: 'rel', object: 'b' }], count: 99 });
    expect(out.neighbors).toEqual(['a', 'b']);
    expect(out.edges.length).toBe(1);
    expect(out.count).toBe(2);
  });
  it('包一層 data（http_request 慣例）→ 取內層；非物件/缺欄位 → 誠實空集合', () => {
    expect(mapGraphWorkflowOutput({ data: { neighbors: ['x'], edges: [] } })).toEqual({ neighbors: ['x'], edges: [], count: 1 });
    expect(mapGraphWorkflowOutput(null)).toEqual({ neighbors: [], edges: [], count: 0 });
    expect(mapGraphWorkflowOutput('oops')).toEqual({ neighbors: [], edges: [], count: 0 });
  });
});

// ═══════════════ 8. t95: normalizeCjkQuery 純函式 ═══════════════

describe('normalizeCjkQuery（t95 CJK/ASCII 邊界補空白）', () => {
  it('純中文 → 不動', () => {
    expect(normalizeCjkQuery('中文')).toBe('中文');
    expect(normalizeCjkQuery('AI 協作')).toBe('AI 協作'); // 已有空白不重複
  });
  it('純 ASCII/數字 → 不動', () => {
    expect(normalizeCjkQuery('ABC123')).toBe('ABC123');
    expect(normalizeCjkQuery('')).toBe('');
  });
  it('CJK→ASCII 邊界插空白', () => {
    expect(normalizeCjkQuery('協作AI')).toBe('協作 AI');
    expect(normalizeCjkQuery('中文1234')).toBe('中文 1234');
  });
  it('ASCII→CJK 邊界插空白', () => {
    expect(normalizeCjkQuery('AI協作')).toBe('AI 協作');
    expect(normalizeCjkQuery('1234中文')).toBe('1234 中文');
  });
  it('已有空白不重複插', () => {
    expect(normalizeCjkQuery('AI 協作規範書')).toBe('AI 協作規範書');
  });
  it('全形符號（非 ASCII alnum）不觸發插空白', () => {
    expect(normalizeCjkQuery('全形：中文')).toBe('全形：中文');
  });
});

// ═══════════════ 9. t96: findBestNodeMatch 純函式 ═══════════════

describe('findBestNodeMatch（t96 fuzzy 節點比對）', () => {
  it('空清單 → null', () => {
    expect(findBestNodeMatch('AI 協作', [])).toBeNull();
  });
  it('完全不包含 → null', () => {
    expect(findBestNodeMatch('量子運算', ['AI 協作規範書', '工作流'])).toBeNull();
  });
  it('精確子字串命中 → 返回', () => {
    expect(findBestNodeMatch('AI 協作', ['AI 協作規範書'])).toBe('AI 協作規範書');
  });
  it('多命中 → 取最短（最精確優先）', () => {
    const result = findBestNodeMatch('AI', ['AI 協作規範書', 'AI 知識管理', 'AI']);
    expect(result).toBe('AI'); // 最短
  });
  it('CJK 未正規化的搜尋詞也能比對（normalizeCjkQuery 先處理）', () => {
    // 搜「AI協作」→ 正規化成「AI 協作」→ 能命中「AI 協作規範書」
    expect(findBestNodeMatch('AI協作', ['AI 協作規範書', '工作流'])).toBe('AI 協作規範書');
  });
  it('大小寫不敏感', () => {
    expect(findBestNodeMatch('ai', ['AI 協作規範書'])).toBe('AI 協作規範書');
  });
});

// ═══════════════ 10. t95: 搜尋 CJK 正規化整合測試 ═══════════════

describe('GET /portal/data/search（t95 CJK 正規化）', () => {
  it('無空白中英混搜尋詞「AI協作」→ KBDB 收到「AI 協作」', async () => {
    await seedSession('tok-cn1', 'rec_3');
    mockGetRecord('rec_3', userValues({ libraries: '["*"]', role: 'admin' }));
    const cap = captureSearch();
    await get('/portal/data/search?q=AI%E5%8D%94%E4%BD%9C', { Authorization: 'Bearer tok-cn1' });
    const sent = new URLSearchParams(cap.url().split('?')[1]);
    expect(sent.get('q')).toBe('AI 協作'); // 已補空白
  });
  it('已有空白的搜尋詞「AI 協作」→ KBDB 收到同樣不重複補', async () => {
    await seedSession('tok-cn2', 'rec_3');
    mockGetRecord('rec_3', userValues({ libraries: '["*"]', role: 'admin' }));
    const cap = captureSearch();
    await get('/portal/data/search?q=AI%20%E5%8D%94%E4%BD%9C', { Authorization: 'Bearer tok-cn2' });
    const sent = new URLSearchParams(cap.url().split('?')[1]);
    expect(sent.get('q')).toBe('AI 協作'); // 無重複空白
  });
});

// ═══════════════ 11. t96: graph neighbors fuzzy fallback 整合測試 ═══════════════

describe('GET /portal/data/graph/neighbors/:name（t96 fuzzy fallback）', () => {
  it('plugin 精確命中有鄰居 → 直接回，不觸發 fallback', async () => {
    await seedSession('tok-gf1', 'rec_a');
    mockGetRecord('rec_a', userValues({ libraries: '["*"]', role: 'admin' }));
    fetchMock
      .get(GRAPH)
      .intercept({ path: (p: string) => p.startsWith('/graph/neighbors/'), method: 'GET' })
      .reply(200, { neighbors: [{ name: '工作流' }], edges: [{ subject: 'AI 協作規範書', predicate: '涵蓋', object: '工作流' }], count: 1 });
    const res = await get('/portal/data/graph/neighbors/AI%20%E5%8D%94%E4%BD%9C%E8%A6%8F%E7%AF%84%E6%9B%B8', { Authorization: 'Bearer tok-gf1' });
    expect(res.status).toBe(200);
    const data = (await res.json()) as { neighbors: unknown[] };
    expect(data.neighbors.length).toBe(1); // 有鄰居直接回
  });

  it('plugin 精確命中 0 鄰居 → fuzzy fallback 找到更長節點名並以它重查', async () => {
    await seedSession('tok-gf2', 'rec_a');
    mockGetRecord('rec_a', userValues({ libraries: '["*"]', role: 'admin' }));
    // 精確命中「AI 協作」→ 0 鄰居
    fetchMock
      .get(GRAPH)
      .intercept({ path: (p: string) => p.includes('AI%20%E5%8D%94%E4%BD%9C') && !p.includes('%E8%A6%8F%E7%AF%84'), method: 'GET' })
      .reply(200, { neighbors: [], edges: [] });
    // KBDB triplets → 含「AI 協作規範書」
    fetchMock
      .get(KBDB)
      .intercept({ path: (p: string) => p.startsWith('/records/by-template/triplet'), method: 'GET' })
      .reply(200, {
        records: [
          { values: { subject: 'AI 協作規範書', predicate: '涵蓋', object: '工作流' } },
          { values: { subject: '工作流', predicate: '使用', object: 'Arcrun' } },
        ],
      });
    // fallback 以「AI 協作規範書」重查 → 有鄰居
    fetchMock
      .get(GRAPH)
      .intercept({ path: (p: string) => p.includes('AI%20%E5%8D%94%E4%BD%9C%E8%A6%8F%E7%AF%84%E6%9B%B8'), method: 'GET' })
      .reply(200, { neighbors: [{ name: '工作流' }], edges: [{ subject: 'AI 協作規範書', predicate: '涵蓋', object: '工作流' }] });
    const res = await get('/portal/data/graph/neighbors/AI%20%E5%8D%94%E4%BD%9C', { Authorization: 'Bearer tok-gf2' });
    expect(res.status).toBe(200);
    const data = (await res.json()) as { neighbors: unknown[] };
    expect(data.neighbors.length).toBe(1); // fallback 帶出鄰居
  });

  it('plugin 精確命中 0 鄰居且 fuzzy 無匹配 → 誠實回 0 鄰居', async () => {
    await seedSession('tok-gf3', 'rec_a');
    mockGetRecord('rec_a', userValues({ libraries: '["*"]', role: 'admin' }));
    fetchMock
      .get(GRAPH)
      .intercept({ path: (p: string) => p.startsWith('/graph/neighbors/'), method: 'GET' })
      .reply(200, { neighbors: [], edges: [] });
    // KBDB triplets → 完全沒有能比對的節點
    fetchMock
      .get(KBDB)
      .intercept({ path: (p: string) => p.startsWith('/records/by-template/triplet'), method: 'GET' })
      .reply(200, { records: [{ values: { subject: '量子運算', predicate: '屬於', object: '物理學' } }] });
    const res = await get('/portal/data/graph/neighbors/%E6%B2%92%E6%9C%89%E9%80%99%E5%80%8B%E7%AF%80%E9%BB%9E', { Authorization: 'Bearer tok-gf3' });
    expect(res.status).toBe(200);
    const data = (await res.json()) as { neighbors: unknown[]; edges: unknown[] };
    expect(data.neighbors.length).toBe(0); // 誠實回 0，不偽造
    expect(data.edges.length).toBe(0);
  });

  it('t95+t96: 無空白「AI協作」→ 正規化成「AI 協作」→ fuzzy 命中「AI 協作規範書」', async () => {
    await seedSession('tok-gf4', 'rec_a');
    mockGetRecord('rec_a', userValues({ libraries: '["*"]', role: 'admin' }));
    // plugin 收到的是正規化後的「AI 協作」（%20 分隔）
    fetchMock
      .get(GRAPH)
      .intercept({ path: (p: string) => p.includes('AI%20%E5%8D%94%E4%BD%9C') && !p.includes('%E8%A6%8F%E7%AF%84'), method: 'GET' })
      .reply(200, { neighbors: [], edges: [] });
    fetchMock
      .get(KBDB)
      .intercept({ path: (p: string) => p.startsWith('/records/by-template/triplet'), method: 'GET' })
      .reply(200, { records: [{ values: { subject: 'AI 協作規範書', predicate: '涵蓋', object: '工作流' } }] });
    fetchMock
      .get(GRAPH)
      .intercept({ path: (p: string) => p.includes('AI%20%E5%8D%94%E4%BD%9C%E8%A6%8F%E7%AF%84%E6%9B%B8'), method: 'GET' })
      .reply(200, { neighbors: [{ name: '工作流' }], edges: [{ subject: 'AI 協作規範書', predicate: '涵蓋', object: '工作流' }] });
    // 前端傳「AI協作」（無空白，URL encoded）
    const res = await get('/portal/data/graph/neighbors/AI%E5%8D%94%E4%BD%9C', { Authorization: 'Bearer tok-gf4' });
    expect(res.status).toBe(200);
    const data = (await res.json()) as { neighbors: unknown[] };
    expect(data.neighbors.length).toBe(1);
  });
});

// ═══════════════ 12. t116: graph_neighbors workflow 補傳 kbdb_base ═══════════════

describe('GET /portal/data/graph/neighbors/:name（t116 kbdb_base 補傳）', () => {
  it('tenant 有 graph_neighbors workflow → portal 傳入 kbdb_base，workflow 正常執行不崩', async () => {
    // 設定 session（["*"] 全庫，放行 graph 粗閘）
    await seedSession('tok-t116', 'rec_t116');
    mockGetRecord('rec_t116', userValues({ libraries: '["*"]', role: 'admin' }));

    // 在 WEBHOOKS KV 放 graph_neighbors workflow（Input→Output 直通）
    // 這個 workflow 不用 {{input.kbdb_base}}，只驗工作流路徑正常執行（不走 graphBase fallback）
    // 若沒補傳 kbdb_base 但 workflow 內有 {{input.kbdb_base}} 的節點，URL 解析失敗 → executeWebhookGraph 回 error
    // 此測試退而求其次：用無外部依賴的直通圖確認整個路徑都通（workflow 取代 plugin fallback）
    const wfKey = `${TENANT}:wf:graph_neighbors`;
    await env.WEBHOOKS.put(wfKey, JSON.stringify({
      graph: {
        id: 'gn-t116',
        name: 'graph_neighbors',
        nodes: [
          { id: 'input', type: 'Input' },
          // comp_passthrough 是內建零件，不需外部 fetch，直接回傳 context
          { id: 'pass', type: 'Component', componentId: 'comp_passthrough' },
          { id: 'output', type: 'Output' },
        ],
        edges: [
          { from: 'input', to: 'pass', type: 'PIPE' },
          { from: 'pass', to: 'output', type: 'PIPE' },
        ],
      },
      description: 't116 test',
      created_at: '2026-07-29T00:00:00.000Z',
    }));

    const res = await get('/portal/data/graph/neighbors/AI%E5%8D%94%E4%BD%9C', { Authorization: 'Bearer tok-t116' });
    expect(res.status).toBe(200);
    const data = (await res.json()) as { neighbors: unknown[]; edges: unknown[]; count: number; kbdb_base?: string };
    // workflow 走 comp_passthrough，output = 整個 context（含 kbdb_base）
    // mapGraphWorkflowOutput 只取 neighbors/edges，其他欄位不影響回應
    expect(Array.isArray(data.neighbors)).toBe(true);
    expect(Array.isArray(data.edges)).toBe(true);
    // 確認不是 502（graph_neighbors workflow 執行失敗）
    expect(res.status).not.toBe(502);

    await env.WEBHOOKS.delete(wfKey);
  });
});

// ═══════════════ 13. t128: graph_neighbors workflow 補傳 template ═══════════════

describe('GET /portal/data/graph/neighbors/:name（t128 template 補傳）', () => {
  it('tenant 有 graph_neighbors workflow → portal 傳入 template=triplet，workflow 不崩', async () => {
    await seedSession('tok-t128', 'rec_t128');
    mockGetRecord('rec_t128', userValues({ libraries: '["*"]', role: 'admin' }));

    const wfKey = `${TENANT}:wf:graph_neighbors`;
    await env.WEBHOOKS.put(wfKey, JSON.stringify({
      graph: {
        id: 'gn-t128',
        name: 'graph_neighbors',
        nodes: [
          { id: 'input', type: 'Input' },
          { id: 'pass', type: 'Component', componentId: 'comp_passthrough' },
          { id: 'output', type: 'Output' },
        ],
        edges: [
          { from: 'input', to: 'pass', type: 'PIPE' },
          { from: 'pass', to: 'output', type: 'PIPE' },
        ],
      },
    }));

    const res = await get('/portal/data/graph/neighbors/AI%E5%8D%94%E4%BD%9C', { Authorization: 'Bearer tok-t128' });
    // template 有進 context → workflow 執行不崩（非 502）
    expect(res.status).toBe(200);
    const data = (await res.json()) as { neighbors: unknown[]; edges: unknown[] };
    expect(Array.isArray(data.neighbors)).toBe(true);

    await env.WEBHOOKS.delete(wfKey);
  });
});

// ═══════════════ 14. t129: dedupeSourcesByPage 純函式 ═══════════════

describe('dedupeSourcesByPage（t129 出處去重）', () => {
  it('同 page_name 合併，hit_count 標計數', () => {
    const srcs = [
      { page_name: '企業版功能', mode: 'semantic', source: 'gitea://docs/enterprise.md' },
      { page_name: '企業版功能', mode: 'semantic', source: 'gitea://docs/enterprise.md' },
      { page_name: '企業版功能', mode: 'keyword', source: 'gitea://docs/enterprise.md' },
    ];
    const out = dedupeSourcesByPage(srcs) as { page_name: string; hit_count?: number }[];
    expect(out.length).toBe(1); // 3 筆→1 筆
    expect(out[0].page_name).toBe('企業版功能');
    expect(out[0].hit_count).toBe(3);
  });

  it('不同 page_name 各保留一筆；單筆無 hit_count', () => {
    const srcs = [
      { page_name: 'A 頁', mode: 'semantic' },
      { page_name: 'B 頁', mode: 'keyword' },
    ];
    const out = dedupeSourcesByPage(srcs) as { page_name: string; hit_count?: number }[];
    expect(out.length).toBe(2);
    expect(out.every(s => s.hit_count === undefined)).toBe(true);
  });

  it('page 欄（備用）也能去重', () => {
    const srcs = [
      { page: '備用頁', mode: 'semantic' },
      { page: '備用頁', mode: 'keyword' },
    ];
    const out = dedupeSourcesByPage(srcs) as { page?: string; hit_count?: number }[];
    expect(out.length).toBe(1);
    expect(out[0].hit_count).toBe(2);
  });

  it('空陣列 → 空陣列；非物件條目跳過', () => {
    expect(dedupeSourcesByPage([])).toEqual([]);
    const out = dedupeSourcesByPage([null, 'oops', { page_name: 'X' }]);
    expect(out.length).toBe(1);
  });

  it('page_name 優先於 page', () => {
    const srcs = [
      { page_name: '優先頁', page: '備用頁' },
      { page_name: '優先頁', page: '備用頁' },
    ];
    const out = dedupeSourcesByPage(srcs) as { hit_count?: number }[];
    expect(out.length).toBe(1); // 同 page_name → 合為一筆
  });
});

// ═══════════════ 7. GET /portal/data/diagnostics（檢修孔，2026-08-07） ═══════════════

describe('GET /portal/data/diagnostics', () => {
  it('未登入 → 401，不碰 KBDB', async () => {
    const res = await get('/portal/data/diagnostics');
    expect(res.status).toBe(401);
  });

  it('登入 → 200，聚合 embed 健康狀態＋規模統計（即時查，非 /map 快取）＋版本；只含數字/布林/字串狀態', async () => {
    // 2026-08-08 修復對應測試：library_count/triplet_count 改走 listRecordsByTemplate(portal_library)
    // ＋ /entries/libraries ＋ /records/triplet-stats（與 GET /portal/admin/libraries 同一套即時查），
    // 不再靠 /map（library_map 快取，recompute 從未被呼叫，恆回空——這正是 08-07 leo 實測抓到的病根）。
    await seedSession('tok-diag1', 'rec_diag1');
    mockGetRecord('rec_diag1', userValues());
    fetchMock
      .get(KBDB)
      .intercept({ path: (p: string) => p.startsWith('/embed/backfill/status'), method: 'GET' })
      .reply(200, { success: true, enabled: true, pending: 3, embedded: 80 });
    fetchMock
      .get(KBDB)
      .intercept({ path: (p: string) => p.startsWith('/embed/selftest'), method: 'GET' })
      .reply(200, { success: true, enabled: true, tested: true, passed: false, note: '搜不到自己' });
    // 已登記庫：1 筆（kb），values 帶不該外流的內容欄位（display_name/description）驗紅線。
    mockLibraryList([
      { record_id: 'rec_lib_kb', values: { name: 'kb', display_name: '不該出現在診斷檔', description: '密卡內容' } },
    ]);
    // 資料裡實際蓋章出現過的庫：kb（與登記簿重複，去重）＋notes（未登記但蓋章過，t52「蓋章即現身」）＋general（fallback 桶，排除不算庫）。
    fetchMock
      .get(KBDB)
      .intercept({ path: (p: string) => p.startsWith('/entries/libraries'), method: 'GET' })
      .reply(200, { success: true, libraries: ['general', 'kb', 'notes'], count: 3 });
    fetchMock
      .get(KBDB)
      .intercept({ path: (p: string) => p.startsWith('/records/triplet-stats'), method: 'GET' })
      .reply(200, { success: true, stats: [{ library: 'kb', triplet_count: 40 }, { library: 'notes', triplet_count: 27 }] });

    const res = await get('/portal/data/diagnostics', { Authorization: 'Bearer tok-diag1' });
    expect(res.status).toBe(200);
    const body = (await res.json()) as {
      library_count: number;
      triplet_count: number;
      library_scope_check: { ran: boolean };
      embedding: { module_enabled: boolean; cards_embedded: number; cards_pending: number; self_test: { ran: boolean; found_itself: boolean | null } };
      instance_url: string;
      bundle_version: string | null;
    };
    expect(body.library_count).toBe(2); // kb（登記簿＋資料面重複，去重）＋notes；general 不算
    expect(body.triplet_count).toBe(67); // 40+27，實際聚合 SQL 算出，非快取
    expect(body.library_scope_check.ran).toBe(false); // 數字不是 0，不需要自我探測
    expect(body.embedding.module_enabled).toBe(true);
    expect(body.embedding.cards_embedded).toBe(80);
    expect(body.embedding.cards_pending).toBe(3);
    expect(body.embedding.self_test.ran).toBe(true);
    expect(body.embedding.self_test.found_itself).toBe(false);
    expect(body.instance_url).toBe('http://localhost');
    // 紅線斷言：整份回應不含知識卡內容本體（登記簿 values 裡的 display_name/description 沒被轉發，只取了 name 算數）
    const raw = JSON.stringify(body);
    expect(raw).not.toContain('不該出現在診斷檔');
    expect(raw).not.toContain('密卡');
    expect(raw).not.toContain('"kb"'); // 連庫名本身都不外流，只回數字
  });

  it('embed 模組未開（自架未開語義搜尋）→ 誠實回 module_enabled:false，不是假裝有 index；庫/三元組真的是空 → 自我探測也回空，不誤判為查詢錯誤', async () => {
    await seedSession('tok-diag2', 'rec_diag2');
    mockGetRecord('rec_diag2', userValues());
    fetchMock
      .get(KBDB)
      .intercept({ path: (p: string) => p.startsWith('/embed/backfill/status'), method: 'GET' })
      .reply(200, { success: true, enabled: false, pending: 0, embedded: 0 });
    fetchMock
      .get(KBDB)
      .intercept({ path: (p: string) => p.startsWith('/embed/selftest'), method: 'GET' })
      .reply(200, { success: true, enabled: false, tested: false, passed: null, note: 'embed 模組未開' });
    mockLibraryList([]);
    fetchMock
      .get(KBDB)
      .intercept({ path: (p: string) => p.startsWith('/entries/libraries'), method: 'GET' })
      .reply(200, { success: true, libraries: [], count: 0 });
    fetchMock
      .get(KBDB)
      .intercept({ path: (p: string) => p.startsWith('/records/triplet-stats'), method: 'GET' })
      .reply(200, { success: true, stats: [] });
    // library_count/triplet_count 都是 0 → 觸發自我探測；這裡探測也回真的空（total:0）。
    fetchMock
      .get(KBDB)
      .intercept({ path: (p: string) => p.startsWith('/entries?'), method: 'GET' })
      .reply(200, { success: true, entries: [], count: 0, total: 0 });

    const res = await get('/portal/data/diagnostics', { Authorization: 'Bearer tok-diag2' });
    expect(res.status).toBe(200);
    const body = (await res.json()) as {
      library_count: number;
      triplet_count: number;
      library_scope_check: { ran: boolean; any_entries_found: boolean | null; note: string };
      embedding: { module_enabled: boolean; self_test: { ran: boolean; found_itself: boolean | null } };
    };
    expect(body.library_count).toBe(0);
    expect(body.triplet_count).toBe(0);
    expect(body.library_scope_check.ran).toBe(true);
    expect(body.library_scope_check.any_entries_found).toBe(false);
    expect(body.embedding.module_enabled).toBe(false);
    expect(body.embedding.self_test.ran).toBe(false);
    expect(body.embedding.self_test.found_itself).toBeNull();
  });

  it('統計自我檢查抓到 t161 同型病：庫/三元組回 0，但這個租戶底下其實查得到其他資料 → 標「像是查詢方式或租戶對不上」而非誤判成真的沒有資料', async () => {
    await seedSession('tok-diag3', 'rec_diag3');
    mockGetRecord('rec_diag3', userValues());
    fetchMock
      .get(KBDB)
      .intercept({ path: (p: string) => p.startsWith('/embed/backfill/status'), method: 'GET' })
      .reply(200, { success: true, enabled: false, pending: 0, embedded: 0 });
    fetchMock
      .get(KBDB)
      .intercept({ path: (p: string) => p.startsWith('/embed/selftest'), method: 'GET' })
      .reply(200, { success: true, enabled: false, tested: false, passed: null, note: 'embed 模組未開' });
    mockLibraryList([]);
    fetchMock
      .get(KBDB)
      .intercept({ path: (p: string) => p.startsWith('/entries/libraries'), method: 'GET' })
      .reply(200, { success: true, libraries: [], count: 0 });
    fetchMock
      .get(KBDB)
      .intercept({ path: (p: string) => p.startsWith('/records/triplet-stats'), method: 'GET' })
      .reply(200, { success: true, stats: [] });
    // 自我探測：這個租戶底下其實有 12 筆 entries——庫/三元組統計卻回 0，兩者矛盾，該被標記。
    fetchMock
      .get(KBDB)
      .intercept({ path: (p: string) => p.startsWith('/entries?'), method: 'GET' })
      .reply(200, { success: true, entries: [{ id: 'e1' }], count: 1, total: 12 });

    const res = await get('/portal/data/diagnostics', { Authorization: 'Bearer tok-diag3' });
    expect(res.status).toBe(200);
    const body = (await res.json()) as {
      library_count: number;
      triplet_count: number;
      library_scope_check: { ran: boolean; any_entries_found: boolean | null; note: string };
    };
    expect(body.library_count).toBe(0);
    expect(body.triplet_count).toBe(0);
    expect(body.library_scope_check.ran).toBe(true);
    expect(body.library_scope_check.any_entries_found).toBe(true);
    expect(body.library_scope_check.note).toContain('查詢方式或租戶對不上');
  });
});

// ═══════════ 8. GET /portal/daemon/diagnostics（t213，daemon 免帳密版檢修孔，2026-08-08） ═══════════
//
// 與上面 /portal/data/diagnostics 共用同一個 buildDiagnostics()（portal.ts）——這裡只驗證
// ①認證換了一套（X-Arcrun-API-Key，非 session）②apiKey 當 owner_id 打 KBDB，不與
// portalTenant(env)（='leo'，見上方 TENANT 常數）比對／不要求相等（t189 教訓）③回應形狀
// 與 session 版一致。核心查詢邏輯已在上面 7 組測試驗過，這裡不重複。
describe('GET /portal/daemon/diagnostics（t213 daemon 版）', () => {
  it('沒帶 X-Arcrun-API-Key → 401，不碰 KBDB', async () => {
    const res = await get('/portal/daemon/diagnostics');
    expect(res.status).toBe(401);
  });

  it('帶 key（刻意與 CONSOLE_TENANT="leo" 不同）→ 200，且 KBDB 查詢用的 owner_id 是這把 key 本身，不是 leo（t189：不假設 apiKey===portalTenant）', async () => {
    const daemonKey = 'yuga3bse'; // 刻意選一個跟 TENANT('leo') 不同的值，比照 t189 geek6688 案例
    fetchMock
      .get(KBDB)
      .intercept({ path: (p: string) => p.startsWith('/embed/backfill/status') && p.includes(`owner_id=${daemonKey}`), method: 'GET' })
      .reply(200, { success: true, enabled: true, pending: 2, embedded: 9 });
    fetchMock
      .get(KBDB)
      .intercept({ path: (p: string) => p.startsWith('/embed/selftest') && p.includes(`owner_id=${daemonKey}`), method: 'GET' })
      .reply(200, { success: true, enabled: true, tested: true, passed: true, note: '' });
    mockLibraryList([{ record_id: 'rec_lib_kb2', values: { name: 'kb' } }]);
    fetchMock
      .get(KBDB)
      .intercept({ path: (p: string) => p.startsWith('/entries/libraries') && p.includes(`owner_id=${daemonKey}`), method: 'GET' })
      .reply(200, { success: true, libraries: ['general', 'kb'], count: 2 });
    fetchMock
      .get(KBDB)
      .intercept({ path: (p: string) => p.startsWith('/records/triplet-stats') && p.includes(`owner_id=${daemonKey}`), method: 'GET' })
      .reply(200, { success: true, stats: [{ library: 'kb', triplet_count: 9 }] });

    const res = await get('/portal/daemon/diagnostics', { 'X-Arcrun-API-Key': daemonKey });
    expect(res.status).toBe(200);
    const body = (await res.json()) as {
      library_count: number;
      triplet_count: number;
      embedding: { module_enabled: boolean; cards_embedded: number };
      instance_url: string;
      bundle_version: string | null;
    };
    expect(body.library_count).toBe(1);
    expect(body.triplet_count).toBe(9);
    expect(body.embedding.module_enabled).toBe(true);
    expect(body.embedding.cards_embedded).toBe(9);
    expect(body.instance_url).toBe('http://localhost');
    // 沒有任何 session 檢查——不打 SESSIONS_KV/portal_user record（本測試從未 seedSession/mockGetRecord
    // 仍然 200，證明這條路徑真的不吃 session）。
  });

  it('回應形狀與 session 版一致（同一組欄位名）', async () => {
    const daemonKey = 'shape-check-key';
    fetchMock
      .get(KBDB)
      .intercept({ path: (p: string) => p.startsWith('/embed/backfill/status'), method: 'GET' })
      .reply(200, { success: true, enabled: false, pending: 0, embedded: 0 });
    fetchMock
      .get(KBDB)
      .intercept({ path: (p: string) => p.startsWith('/embed/selftest'), method: 'GET' })
      .reply(200, { success: true, enabled: false, tested: false, passed: null, note: '' });
    mockLibraryList([]);
    fetchMock
      .get(KBDB)
      .intercept({ path: (p: string) => p.startsWith('/entries/libraries'), method: 'GET' })
      .reply(200, { success: true, libraries: [], count: 0 });
    fetchMock
      .get(KBDB)
      .intercept({ path: (p: string) => p.startsWith('/records/triplet-stats'), method: 'GET' })
      .reply(200, { success: true, stats: [] });
    fetchMock
      .get(KBDB)
      .intercept({ path: (p: string) => p.startsWith('/entries?'), method: 'GET' })
      .reply(200, { success: true, entries: [], count: 0, total: 0 });

    const res = await get('/portal/daemon/diagnostics', { 'X-Arcrun-API-Key': daemonKey });
    expect(res.status).toBe(200);
    const body = (await res.json()) as Record<string, unknown>;
    expect(Object.keys(body).sort()).toEqual(
      ['generated_at', 'instance_url', 'bundle_version', 'library_count', 'triplet_count', 'library_scope_check', 'embedding', 'notes'].sort(),
    );
    // t213 leo 08-08 指令：舊的「需在失敗當下截圖」那句已刪，notes 不該再含這句話。
    expect(JSON.stringify(body.notes)).not.toContain('截圖');
  });
});

// ═══ Arcrun#100: 總圖的「0」只准在真的是 0 的時候出現 ═══

describe('GET /portal/data/graph/overview（#100 空圖三態）', () => {
  /** KBDB `/records/triplet-stats`：帶 owner 與不帶 owner 是兩條不同路徑，分別攔。 */
  function mockCount(scoped: number | null, global?: number | null) {
    fetchMock
      .get(KBDB)
      .intercept({ path: (p: string) => p.startsWith(`/records/triplet-stats?owner_id=${TENANT}`), method: 'GET' })
      .reply(scoped === null ? 500 : 200, scoped === null ? { error: 'boom' } : { success: true, stats: [{ library: 'general', triplet_count: scoped }] });
    if (global !== undefined) {
      fetchMock
        .get(KBDB)
        .intercept({ path: (p: string) => p === '/records/triplet-stats?owner_id=', method: 'GET' })
        .reply(global === null ? 500 : 200, global === null ? { error: 'boom' } : { success: true, stats: [{ library: 'general', triplet_count: global }] });
    }
  }
  function mockTriplets(body: object, status = 200) {
    fetchMock
      .get(KBDB)
      .intercept({ path: (p: string) => p.startsWith('/records/by-template/triplet'), method: 'GET' })
      .reply(status, body);
  }
  async function overview(token: string) {
    await seedSession(token, `rec_${token}`);
    mockGetRecord(`rec_${token}`, userValues({ libraries: '["*"]', role: 'admin' }));
    return get('/portal/data/graph/overview', { Authorization: `Bearer ${token}` });
  }

  it('有資料 → 照常回圖，並附上全庫真實條數', async () => {
    mockTriplets({ success: true, records: [{ values: { subject: 'A', predicate: '連到', object: 'B' } }] });
    mockCount(1854);
    const res = await overview('tok-ov1');
    expect(res.status).toBe(200);
    const d = (await res.json()) as { node_count: number; triplets_total: number; empty_confirmed: boolean };
    expect(d.node_count).toBe(2);
    expect(d.triplets_total).toBe(1854);
    expect(d.empty_confirmed).toBe(true);
  });

  it('真的空（本租戶 0、全庫也 0）→ empty_confirmed=true，畫面才准印 0', async () => {
    mockTriplets({ success: true, records: [] });
    mockCount(0, 0);
    const res = await overview('tok-ov2');
    const d = (await res.json()) as { node_count: number; empty_confirmed: boolean; empty_reason: string };
    expect(d.node_count).toBe(0);
    expect(d.empty_confirmed).toBe(true);
    expect(d.empty_reason).toBe('confirmed_empty');
  });

  it('🔴 反向：本租戶查到 0、全庫卻有 1854（t161 owner_id 對不上）→ 不准說空，回 scope_mismatch', async () => {
    mockTriplets({ success: true, records: [] });
    mockCount(0, 1854);
    const res = await overview('tok-ov3');
    const d = (await res.json()) as { empty_confirmed: boolean; empty_reason: string };
    expect(d.empty_confirmed).toBe(false);
    expect(d.empty_reason).toBe('scope_mismatch');
  });

  it('🔴 反向：條數讀不到 → unreadable（不是 confirmed_empty，畫面顯示「讀不到」）', async () => {
    mockTriplets({ success: true, records: [] });
    mockCount(null);
    const res = await overview('tok-ov4');
    const d = (await res.json()) as { empty_confirmed: boolean; empty_reason: string; triplets_total: number | null };
    expect(d.empty_confirmed).toBe(false);
    expect(d.empty_reason).toBe('unreadable');
    expect(d.triplets_total).toBeNull();
  });

  it('🔴 反向：有條數卻一條邊都抽不出來 → scope_mismatch，不是空庫', async () => {
    mockTriplets({ success: true, records: [{ values: { subject: '', object: '' } }] });
    mockCount(1854);
    const res = await overview('tok-ov5');
    const d = (await res.json()) as { node_count: number; empty_confirmed: boolean; empty_reason: string };
    expect(d.node_count).toBe(0);
    expect(d.empty_reason).toBe('scope_mismatch');
    expect(d.empty_confirmed).toBe(false);
  });

  it('🔴 反向：KBDB 回應形狀不對（沒有 records 陣列）→ 502，不再回一張空圖', async () => {
    mockTriplets({ success: true, items: [] }); // 欄位名不對＝讀不出來
    mockCount(1854);
    const res = await overview('tok-ov6');
    expect(res.status).toBe(502);
    const d = (await res.json()) as { error: string };
    expect(d.error).toContain('三元組讀取失敗');
  });
});
