/**
 * Arcrun#108 — 藏書地圖看得到自己的知識（租戶字串來源收斂）。
 *
 * 釘住的事實：
 *   1. 資料面 owner_id 來自 `knowledgeOwner(env)`：`ARCRUN_NAMESPACE` 優先、`CONSOLE_TENANT` 回退、
 *      兩者皆無 → 丟 TenantUnresolvedError（**沒有 `|| 'leo'` 這種靜默預設值**）。
 *   2. `/portal/data/map` 真的拿那個值去打 KBDB（leo 的情境：ARCRUN_NAMESPACE=bfezv28v
 *      → 打 `owner_id=bfezv28v` 拿回 9 個庫，而不是打 `owner_id=leo` 拿回 0 個）。
 *   3. **權限沒有被拿掉**：同一份 KBDB 回應，庫權限 ["kb"] 的帳號只看得到 kb。
 *   4. 空地圖分得出四種成因（#100 那條「讀不到就說讀不到」延伸到藏書地圖）：
 *      no_library_grant / filtered_out / scope_mismatch / confirmed_empty。
 *   5. 回應**不含租戶字串**（design §3.3 紅線：前端拿到就能繞過庫過濾直打 /kbdb/*）。
 */
import { env, fetchMock } from 'cloudflare:test';
import { beforeAll, afterEach, describe, it, expect } from 'vitest';
import { knowledgeOwner, accountTenant, TenantUnresolvedError, ownerQuery, censusQueryAllTenants } from '../src/lib/tenant';
import { portalDataRouter } from '../src/routes/portal-data';
import type { Bindings } from '../src/types';

const KBDB = 'https://kbdb.test';
/** leo 的真實命名空間（2026-08-11 回灌時定名，見 Leo/mira#8）。 */
const LEO_NS = 'bfezv28v';

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

/**
 * 直接餵 router 一份 env（不是 SELF.fetch）——`cloudflare:test` 的 `env` 物件改了不會傳進
 * SELF 那個 worker（實測：改 ARCRUN_BUNDLE_VERSION 後 /health 仍回舊值），
 * 而本票要驗的正是「換一個命名空間，查詢就跟著換」。Hono router 吃 env 參數，
 * 走的是同一支 handler、同一條 KBDB fetch，只有 env 這一項是測試給的。
 */
const ctx = { waitUntil: () => {}, passThroughOnException: () => {} } as unknown as ExecutionContext;

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

function mockGetRecord(recordId: string, libraries: string) {
  fetchMock
    .get(KBDB)
    .intercept({ path: `/records/${recordId}`, method: 'GET' })
    .reply(200, {
      success: true,
      record: {
        record_id: recordId,
        template_id: 'tpl_pu',
        values: {
          email: 'leo@example.com',
          display_name: 'leo',
          status: 'active',
          role: 'admin',
          password_hash: 'pbkdf2-sha256$600000$AA$BB',
          libraries,
          created_at: '2026-08-12T00:00:00.000Z',
          updated_at: '2026-08-12T00:00:00.000Z',
        },
      },
    });
}

/** 攔 `/map`，同時把「實際被查詢的 owner_id」記下來給斷言用。 */
function mockMap(libraries: { library: string; triplet_count: number }[], seen: string[]) {
  fetchMock
    .get(KBDB)
    .intercept({
      path: (p: string) => {
        if (!p.startsWith('/map')) return false;
        seen.push(new URL(p, KBDB).searchParams.get('owner_id') ?? '');
        return true;
      },
      method: 'GET',
    })
    .reply(200, { success: true, libraries, count: libraries.length });
}

function mockTripletStats(match: (ownerId: string) => boolean, tripletCount: number) {
  fetchMock
    .get(KBDB)
    .intercept({
      path: (p: string) =>
        p.startsWith('/records/triplet-stats') && match(new URL(p, KBDB).searchParams.get('owner_id') ?? ''),
      method: 'GET',
    })
    .reply(200, { success: true, stats: [{ library: 'kb', triplet_count: tripletCount }] });
}

async function getMap(token: string, overrides: Partial<Bindings> = {}) {
  const res = await portalDataRouter.fetch(
    new Request('http://localhost/portal/data/map', { headers: { authorization: `Bearer ${token}` } }),
    { ...env, ...overrides } as Bindings,
    ctx,
  );
  return { status: res.status, body: (await res.json()) as Record<string, unknown> };
}

/** undici 的 path matcher 可能被呼叫多次 → 比對前先去重（我們在意的是「查了哪些 owner_id」）。 */
const distinct = (xs: string[]): string[] => [...new Set(xs)];

// ── ① 唯一產地的解析順序 ───────────────────────────────────────────────────────

describe('knowledgeOwner：租戶字串只有一個產地，且沒有靜默預設值', () => {
  it('ARCRUN_NAMESPACE 優先（＝acr update 從 ~/.arcrun/config.yaml 的 api_key 注入的那個值）', () => {
    expect(knowledgeOwner({ ARCRUN_NAMESPACE: LEO_NS, CONSOLE_TENANT: 'leo' } as Bindings)).toBe(LEO_NS);
  });

  it('沒注入 → 回退 CONSOLE_TENANT（官方 prod 與尚未 acr update 的實例，行為一字不變）', () => {
    expect(knowledgeOwner({ CONSOLE_TENANT: 'leo' } as Bindings)).toBe('leo');
  });

  it('空字串不算數（部署把 var 設成空字串 ≠ 有設定）', () => {
    expect(knowledgeOwner({ ARCRUN_NAMESPACE: '   ', CONSOLE_TENANT: 'leo' } as Bindings)).toBe('leo');
  });

  it('兩個都沒有 → 丟 TenantUnresolvedError，**不回 "leo"**（靜默預設值正是本票的病）', () => {
    expect(() => knowledgeOwner({} as Bindings)).toThrow(TenantUnresolvedError);
  });

  it('帳號層 accountTenant 不受影響（改它會讓舊實例登不進去，所以刻意不動）', () => {
    expect(accountTenant({ ARCRUN_NAMESPACE: LEO_NS, CONSOLE_TENANT: 'leo' } as Bindings)).toBe('leo');
    expect(accountTenant({} as Bindings)).toBe('leo');
  });

  it('過濾片段只有兩種形狀：帶租戶的 ownerQuery，與明著喊全庫的普查', () => {
    expect(ownerQuery(knowledgeOwner({ ARCRUN_NAMESPACE: 'a b' } as Bindings))).toBe('owner_id=a%20b');
    expect(censusQueryAllTenants()).toBe('owner_id=');
  });
});

// ── ② 地圖真的用那個 owner_id 去查 ─────────────────────────────────────────────

describe('GET /portal/data/map — leo 的情境（1854 條 → 看得到，不是 0 個庫）', () => {
  it('注入 ARCRUN_NAMESPACE 後，KBDB 收到的 owner_id 是它，而且庫都回得來', async () => {
    await seedSession('t-map-1', 'rec_leo');
    mockGetRecord('rec_leo', '["*"]');
    const seen: string[] = [];
    mockMap(
      [
        { library: 'kb', triplet_count: 1851 },
        { library: 'general', triplet_count: 3 },
      ],
      seen,
    );

    const { status, body } = await getMap('t-map-1', { ARCRUN_NAMESPACE: LEO_NS });
    expect(status).toBe(200);
    expect(distinct(seen)).toEqual([LEO_NS]); // ← 這一行就是本票：以前送出去的是 'leo'
    expect(body.count).toBe(2);
    expect((body.libraries as { library: string; triplet_count: number }[]).map((l) => l.triplet_count))
      .toEqual([1851, 3]);
    expect(body.empty_reason).toBeNull();
  });

  it('回應不含租戶字串（前端拿到就能繞過庫過濾直打 /kbdb/*——design §3.3 紅線）', async () => {
    await seedSession('t-map-2', 'rec_leo2');
    mockGetRecord('rec_leo2', '["*"]');
    mockMap([{ library: 'kb', triplet_count: 1851 }], []);

    const { body } = await getMap('t-map-2', { ARCRUN_NAMESPACE: LEO_NS });
    expect(JSON.stringify(body)).not.toContain(LEO_NS);
    expect(JSON.stringify(body)).not.toContain('ARCRUN_NAMESPACE');
  });

  it('沒注入時沿用 CONSOLE_TENANT（未跑 acr update 的實例行為不變，這次改動對它是惰性的）', async () => {
    await seedSession('t-map-3', 'rec_leo3');
    mockGetRecord('rec_leo3', '["*"]');
    const seen: string[] = [];
    mockMap([{ library: 'kb', triplet_count: 1 }], seen);

    await getMap('t-map-3');
    expect(distinct(seen)).toEqual(['leo']); // wrangler.test.toml CONSOLE_TENANT
  });
});

// ── ③ 權限沒有被拿掉（紅線：修這題不准把 owner_id 過濾或庫過濾拆掉）──────────────

describe('權限：只被授權部分庫的帳號，只看得到那幾個庫', () => {
  it('libraries=["kb"] → 同一份 KBDB 回應裡只剩 kb', async () => {
    await seedSession('t-perm-1', 'rec_partial');
    mockGetRecord('rec_partial', '["kb"]');
    mockMap(
      [
        { library: 'kb', triplet_count: 1851 },
        { library: 'finance', triplet_count: 42 },
        { library: 'general', triplet_count: 3 },
      ],
      [],
    );

    const { body } = await getMap('t-perm-1', { ARCRUN_NAMESPACE: LEO_NS });
    expect((body.libraries as { library: string }[]).map((l) => l.library)).toEqual(['kb']);
    expect(body.count).toBe(1);
  });

  it('一個庫都沒被授權 → 不打 KBDB，誠實說是權限問題', async () => {
    await seedSession('t-perm-2', 'rec_nolib');
    mockGetRecord('rec_nolib', '[]');
    const { body } = await getMap('t-perm-2'); // 沒有 mockMap：打了就會 assertNoPendingInterceptors 失敗
    expect(body.count).toBe(0);
    expect(body.empty_reason).toBe('no_library_grant');
    expect(body.empty_confirmed).toBe(true);
  });

  it('實例有庫但都不在權限內 → filtered_out（是隔離正常，不是資料不見）', async () => {
    await seedSession('t-perm-3', 'rec_other');
    mockGetRecord('rec_other', '["finance"]');
    mockMap([{ library: 'kb', triplet_count: 1851 }], []);

    const { body } = await getMap('t-perm-3', { ARCRUN_NAMESPACE: LEO_NS });
    expect(body.empty_reason).toBe('filtered_out');
    expect(body.empty_confirmed).toBe(true);
  });
});

// ── ④ 空地圖的四種成因分得出來（不再把設定錯誤畫成「你沒有資料」）────────────────

describe('空地圖：分得出「讀不到」與「沒有」', () => {
  it('命名空間對不上（本租戶 0、整台實例有）→ scope_mismatch，並指出該跑 acr update', async () => {
    await seedSession('t-empty-1', 'rec_e1');
    mockGetRecord('rec_e1', '["*"]');
    mockMap([], []);
    mockTripletStats((o) => o === 'wrong-ns', 0); // 本租戶 0
    mockTripletStats((o) => o === '', 1854); // 全庫普查：有 1854 條

    const { body } = await getMap('t-empty-1', { ARCRUN_NAMESPACE: 'wrong-ns' });
    expect(body.empty_reason).toBe('scope_mismatch');
    expect(body.empty_confirmed).toBe(false); // 🔴 絕不宣稱「你沒有資料」
    expect(body.instance_triplet_count).toBe(1854);
    expect(String(body.note)).toContain('acr update');
    expect(JSON.stringify(body)).not.toContain('wrong-ns'); // 仍不下發租戶字串
  });

  it('整台實例真的空 → confirmed_empty（此時、也只有此時，才准說「還沒有內容」）', async () => {
    await seedSession('t-empty-2', 'rec_e2');
    mockGetRecord('rec_e2', '["*"]');
    mockMap([], []);
    mockTripletStats((o) => o === 'leo', 0);
    mockTripletStats((o) => o === '', 0);

    const { body } = await getMap('t-empty-2');
    expect(body.empty_reason).toBe('confirmed_empty');
    expect(body.empty_confirmed).toBe(true);
  });

  it('連統計都讀不到 → unreadable（不假裝是空庫）', async () => {
    await seedSession('t-empty-3', 'rec_e3');
    mockGetRecord('rec_e3', '["*"]');
    mockMap([], []);
    fetchMock
      .get(KBDB)
      .intercept({ path: (p: string) => p.startsWith('/records/triplet-stats'), method: 'GET' })
      .reply(500, { error: 'boom' });

    const { body } = await getMap('t-empty-3');
    expect(body.empty_reason).toBe('unreadable');
    expect(body.empty_confirmed).toBe(false);
  });
});
