// @vitest-environment node
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import express from 'express';
import http from 'http';
import type Provider from 'oidc-provider';
import { createHash, randomBytes } from 'crypto';
import { mkdtempSync } from 'fs';
import { tmpdir } from 'os';
import { join } from 'path';
import { Repository } from '../../db/repository.js';
import { createA2aOidcProvider, mountA2aOidc } from './oidc-provider.js';
import { createA2aRouter } from './a2a-api.js';
import { b64url, Client } from './__e2e-helpers.js';

/**
 * Plan 1（実 oidc-provider）+ Plan 2A（inbound trust core + agent card）の統合 e2e。
 *
 * 同一 http server に「実 oidc provider + consent」と「A2A agent card ルータ」を相乗りさせ、
 * 本物の authorization_code + PKCE フローで発行したトークンが extended card ルートで
 * 受理され、consent で選んだスペース/スキルだけにスコープされることを実証する。
 * 否定経路（Bearer 無し / 失効後 / 委任外スキル）も同じ実トークンで検証する。
 */
describe('A2A card e2e (real token → scoped extended card + negative paths)', () => {
  let server: http.Server;
  let base: string;
  let audience: string;
  let repo: Repository;
  let provider: Provider;

  const clientId = 'a2a_card_e2e';
  const redirectUri = 'https://client.test/cb';
  const userId = 'user-1';
  const spaceId = 'space-research';

  beforeAll(async () => {
    repo = new Repository(':memory:');

    // 公開 A2A クライアント（PKCE / token_endpoint_auth_method=none）。
    repo.createA2aClient({
      clientId, name: 'Card E2E', redirectUris: [redirectUri],
      secretHash: null, tokenEndpointAuthMethod: 'none',
      agentCardUrl: null, issuer: null, keyFingerprint: null, status: 'active', createdBy: 'admin',
    });

    // acting user（extended card ルートが getUserById で実ロール/組織を引くため実在が必要）。
    (repo as any).db.prepare(
      "INSERT INTO users (id, email, name, avatar_url, role, status, created_at, updated_at) " +
      "VALUES (?, ?, ?, NULL, 'user', 'active', '2026-01-01', '2026-01-01')",
    ).run(userId, 'u1@test', 'User One');

    // user-1 が所有する案件スペース + A2A 公開許可リスト（research / summarize）。
    (repo as any).db.prepare(
      "INSERT INTO spaces (id, title, kind, owner_id, visibility) VALUES (?, 'Research', 'case', ?, 'private')",
    ).run(spaceId, userId);
    repo.setSpaceA2aSkills(spaceId, ['research', 'summarize']);

    // クロススペース AND 交差テスト用スペース（space-a に research / space-b に analyze）。
    // 同意は space-a のみに絞ることで、space-b の analyze がカードに漏れないことを検証する。
    (repo as any).db.prepare(
      "INSERT INTO spaces (id, title, kind, owner_id, visibility) VALUES (?, 'Space A', 'case', ?, 'private')",
    ).run('space-a', userId);
    repo.setSpaceA2aSkills('space-a', ['research']);
    (repo as any).db.prepare(
      "INSERT INTO spaces (id, title, kind, owner_id, visibility) VALUES (?, 'Space B', 'case', ?, 'private')",
    ).run('space-b', userId);
    repo.setSpaceA2aSkills('space-b', ['analyze']);

    const secretsDir = mkdtempSync(join(tmpdir(), 'a2a-card-e2e-'));
    const app = express();
    // 擬似ログイン: 全リクエストに user を注入（consent の req.user 用）。mount より前に置く。
    app.use((req, _res, next) => { (req as any).user = { id: userId, role: 'user' }; next(); });

    // listen → port 確定 → issuer/audience 確定 → provider 生成 → mount の順（テストのみ並び替え）
    server = http.createServer(app);
    await new Promise<void>(r => server.listen(0, '127.0.0.1', r));
    const port = (server.address() as any).port;
    base = `http://127.0.0.1:${port}`;
    audience = `${base}/a2a`;

    provider = createA2aOidcProvider({
      repo, secretsDir,
      issuer: `${base}/oidc`,
      resourceAudience: audience,
      cookieKeys: ['test-cookie-key'],
    });
    // テスト専用: http/127.0.0.1 で cookie を non-secure にして cookie jar が機能するようにする。
    (provider as any).proxy = false;

    // 実 provider + consent を /oidc に、A2A card ルータを同じ app にマウント。
    mountA2aOidc(app, provider, { repo, resourceAudience: audience });
    app.use(createA2aRouter({
      provider, repo,
      baseUrl: base,
      issuer: `${base}/oidc`,
      version: '1.0.0-test',
    }));
  });

  afterAll(() => { server?.close(); });

  /**
   * authorize → consent（space/skill を選択）→ code 取得 → token 交換まで通し、
   * access_token と、そのトークンに紐づく grantId を返す。
   * consent confirm のボディは consent-api.ts が読む `selectedSpaces` / `selectedSkills`。
   */
  async function obtainToken(
    selectedSpaces: string,
    selectedSkills: string,
  ): Promise<{ accessToken: string; grantId: string }> {
    const client = new Client();
    const verifier = b64url(randomBytes(32));
    const challenge = b64url(createHash('sha256').update(verifier).digest());

    const authUrl = `${base}/oidc/auth?` + new URLSearchParams({
      client_id: clientId, response_type: 'code', redirect_uri: redirectUri,
      scope: 'openid a2a.read', resource: audience,
      code_challenge: challenge, code_challenge_method: 'S256', state: 'xyz',
    });
    const authRes = await client.get(authUrl);
    expect([302, 303]).toContain(authRes.status);
    let loc = authRes.headers.get('location')!;

    // login / consent interaction を redirect_uri に着くまでループ（cookie jar 経由）。
    // confirm のたびに space/skill 選択を送る（最終 grant に委任が紐づく）。
    for (let i = 0; i < 5; i++) {
      if (loc.startsWith(redirectUri)) break;
      expect(loc).toContain('/oidc/interaction/');
      const uid = new URL(loc, base).pathname.split('/').pop()!;
      const confirmRes = await client.postForm(`${base}/oidc/interaction/${uid}/confirm`, {
        selectedSpaces, selectedSkills,
      });
      expect(confirmRes.status).toBe(200);
      const { redirectTo } = await confirmRes.json() as { redirectTo: string };
      expect(redirectTo).toContain('/oidc/auth/');
      const resumeRes = await client.get(redirectTo);
      expect([302, 303]).toContain(resumeRes.status);
      loc = resumeRes.headers.get('location')!;
    }

    expect(loc.startsWith(redirectUri)).toBe(true);
    const code = new URL(loc).searchParams.get('code');
    expect(code).toBeTruthy();

    const tokenRes = await client.postForm(`${base}/oidc/token`, {
      grant_type: 'authorization_code',
      code: code!, code_verifier: verifier, client_id: clientId, redirect_uri: redirectUri,
    });
    expect(tokenRes.status).toBe(200);
    const tokenJson = await tokenRes.json() as { access_token: string; scope: string };
    expect(tokenJson.access_token).toBeTruthy();
    expect(tokenJson.scope.split(' ')).toContain('a2a.read');

    // opaque access token から grantId を引く（失効テストで委任を特定するため）。
    const at = await (provider as any).AccessToken.find(tokenJson.access_token);
    expect(at?.grantId).toBeTruthy();
    return { accessToken: tokenJson.access_token, grantId: at.grantId as string };
  }

  it('happy path: real token → extended card scoped to the single consented skill', async () => {
    const { accessToken } = await obtainToken(spaceId, 'research');

    const res = await fetch(`${base}/a2a/agent-card/extended`, {
      headers: { authorization: `Bearer ${accessToken}` },
    });
    expect(res.status).toBe(200);
    const card = await res.json() as { skills: Array<{ id: string }> };
    // research のみ consent → summarize は許可リストにあっても出ない
    expect(card.skills.map(s => s.id)).toEqual(['research']);
  });

  it('base card is public (no auth): skills [] + supportsAuthenticatedExtendedCard', async () => {
    const res = await fetch(`${base}/.well-known/agent-card.json`);
    expect(res.status).toBe(200);
    const card = await res.json() as { skills: unknown[]; supportsAuthenticatedExtendedCard: boolean };
    expect(card.skills).toEqual([]);
    expect(card.supportsAuthenticatedExtendedCard).toBe(true);
  });

  it('no bearer → 401 on the extended card', async () => {
    const res = await fetch(`${base}/a2a/agent-card/extended`);
    expect(res.status).toBe(401);
  });

  it('revoked delegation → 401 with the same previously-working token', async () => {
    const { accessToken, grantId } = await obtainToken(spaceId, 'research');

    // まず動くことを確認
    const okRes = await fetch(`${base}/a2a/agent-card/extended`, {
      headers: { authorization: `Bearer ${accessToken}` },
    });
    expect(okRes.status).toBe(200);

    // grantId から委任を特定して失効
    const delegation = repo.getA2aDelegationByGrantId(grantId);
    expect(delegation).toBeTruthy();
    repo.revokeA2aDelegation(delegation!.id, new Date().toISOString());

    // 同じトークンは委任が live でないため 401
    const res = await fetch(`${base}/a2a/agent-card/extended`, {
      headers: { authorization: `Bearer ${accessToken}` },
    });
    expect(res.status).toBe(401);
  });

  it('out-of-scope skill dropped: summarize allowlisted but not consented is absent', async () => {
    // research だけ同意。summarize はスペース許可リストにあるが委任に含めない。
    const { accessToken } = await obtainToken(spaceId, 'research');

    const res = await fetch(`${base}/a2a/agent-card/extended`, {
      headers: { authorization: `Bearer ${accessToken}` },
    });
    expect(res.status).toBe(200);
    const card = await res.json() as { skills: Array<{ id: string }> };
    const ids = card.skills.map(s => s.id);
    expect(ids).toContain('research');
    expect(ids).not.toContain('summarize');
  });

  it('cross-space AND-intersection: non-granted space skills are absent from extended card', async () => {
    // space-a（research）と space-b（analyze）が両方ユーザーから見えるが、
    // space-a のみに同意 → resolveEffectiveSpaces が space-b を AND 交差で排除し、
    // analyze がカードに漏れないことを end-to-end で実証する（最高 IDOR リスク経路）。
    const { accessToken } = await obtainToken('space-a', 'research');

    const res = await fetch(`${base}/a2a/agent-card/extended`, {
      headers: { authorization: `Bearer ${accessToken}` },
    });
    expect(res.status).toBe(200);
    const card = await res.json() as { skills: Array<{ id: string }> };
    const ids = card.skills.map(s => s.id);
    // space-a の research は同意済み → 出現する
    expect(ids).toEqual(['research']);
    // space-b の analyze は未同意スペースにのみ存在 → 漏れてはならない
    expect(ids).not.toContain('analyze');
  });
});
