// @vitest-environment node
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import express from 'express';
import http from 'http';
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 { b64url, Client } from './__e2e-helpers.js';

describe('A2A oidc e2e (authorization_code + PKCE)', () => {
  let server: http.Server;
  let base: string;
  let audience: string;
  let repo: Repository;
  const clientId = 'a2a_test';
  const redirectUri = 'https://client.test/cb';

  beforeAll(async () => {
    repo = new Repository(':memory:');
    repo.createA2aClient({
      clientId, name: 'E2E', redirectUris: [redirectUri],
      secretHash: null, tokenEndpointAuthMethod: 'none',
      agentCardUrl: null, issuer: null, keyFingerprint: null, status: 'active', createdBy: 'admin',
    });
    // disabled なクライアント（否定経路用）。最初から disabled なので Client キャッシュにも乗らない。
    repo.createA2aClient({
      clientId: 'a2a_disabled', name: 'Disabled', redirectUris: [redirectUri],
      secretHash: null, tokenEndpointAuthMethod: 'none',
      agentCardUrl: null, issuer: null, keyFingerprint: null, status: 'active', createdBy: 'admin',
    });
    repo.setA2aClientStatus('a2a_disabled', 'disabled');

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

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

    mountA2aOidc(app, provider, { repo, resourceAudience: audience });
  });

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

  /** authorize → consent → code 取得まで通し、認可コードを返す。 */
  async function obtainCode(client: Client, verifier: string): Promise<string> {
    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')!;

    // oidc-provider は login と consent を別々の interaction として要求しうるので、
    // interaction → confirm → resume を redirect_uri に着くまでループする（cookie jar 経由）。
    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`, {});
      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();
    return code!;
  }

  it('issues a scoped token, introspection reflects active + aud, then revocation flips to inactive', async () => {
    const client = new Client();
    const verifier = b64url(randomBytes(32));
    const code = await obtainCode(client, verifier);

    // token 交換（public client → client_id をボディに）
    const tokenRes = await client.postForm(`${base}/oidc/token`, {
      grant_type: 'authorization_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; token_type: string };
    expect(tokenJson.access_token).toBeTruthy();
    expect(tokenJson.scope.split(' ')).toContain('a2a.read');

    // introspection: active + aud
    const introRes = await client.postForm(`${base}/oidc/token/introspection`, {
      token: tokenJson.access_token, client_id: clientId,
    });
    expect(introRes.status).toBe(200);
    const introBefore = await introRes.json() as { active: boolean; aud?: string | string[]; scope?: string };
    expect(introBefore.active).toBe(true);
    const audValue = Array.isArray(introBefore.aud) ? introBefore.aud : [introBefore.aud];
    expect(audValue).toContain(audience);
    expect(String(introBefore.scope ?? '').split(' ')).toContain('a2a.read');

    // revocation
    const revRes = await client.postForm(`${base}/oidc/token/revocation`, {
      token: tokenJson.access_token, client_id: clientId,
    });
    expect(revRes.status).toBe(200);

    // revoke 後の introspection は inactive
    const introAfterRes = await client.postForm(`${base}/oidc/token/introspection`, {
      token: tokenJson.access_token, client_id: clientId,
    });
    expect(introAfterRes.status).toBe(200);
    const introAfter = await introAfterRes.json() as { active: boolean };
    expect(introAfter.active).toBe(false);
  });

  it('rejects an unregistered client_id at the authorize endpoint', async () => {
    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: 'no_such_client', response_type: 'code', redirect_uri: redirectUri,
      scope: 'openid a2a.read', resource: audience,
      code_challenge: challenge, code_challenge_method: 'S256', state: 'xyz',
    });
    const res = await client.get(authUrl);
    // 未登録 client は redirect_uri を信頼できないため、リダイレクトせず 400 エラーを返す
    expect(res.status).toBe(400);
    const body = await res.text();
    expect(body).toMatch(/invalid_client|unrecognized|client/i);
  });

  it('rejects authorize when code_challenge is missing (PKCE required)', async () => {
    const client = new Client();
    const authUrl = `${base}/oidc/auth?` + new URLSearchParams({
      client_id: clientId, response_type: 'code', redirect_uri: redirectUri,
      scope: 'openid a2a.read', resource: audience, state: 'xyz',
      // code_challenge を意図的に省略
    });
    const res = await client.get(authUrl);
    // 既知 client + 既知 redirect_uri なので、エラーは redirect_uri に返される（303 + error=invalid_request）
    expect([302, 303]).toContain(res.status);
    const loc = res.headers.get('location')!;
    const err = new URL(loc).searchParams.get('error');
    expect(err).toBe('invalid_request');
    expect(new URL(loc).searchParams.get('error_description') ?? '').toMatch(/code_challenge|pkce/i);
  });

  it('cannot start an authorize flow with a disabled client', async () => {
    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: 'a2a_disabled', response_type: 'code', redirect_uri: redirectUri,
      scope: 'openid a2a.read', resource: audience,
      code_challenge: challenge, code_challenge_method: 'S256', state: 'xyz',
    });
    const res = await client.get(authUrl);
    // disabled client は adapter.find が undefined を返す → 未知 client 扱い → 400
    expect(res.status).toBe(400);
    const body = await res.text();
    expect(body).toMatch(/invalid_client|unrecognized|client/i);
  });

  it('rejects token exchange with a tampered code_verifier (invalid_grant)', async () => {
    const client = new Client();
    const verifier = b64url(randomBytes(32));
    const code = await obtainCode(client, verifier);

    const tampered = b64url(randomBytes(32)); // 正しい verifier と一致しない
    const tokenRes = await client.postForm(`${base}/oidc/token`, {
      grant_type: 'authorization_code',
      code, code_verifier: tampered, client_id: clientId, redirect_uri: redirectUri,
    });
    expect(tokenRes.status).toBe(400);
    const body = await tokenRes.json() as { error: string };
    expect(body.error).toBe('invalid_grant');
  });
});
