// @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, randomUUID } from 'crypto';
import { mkdtempSync, mkdirSync, writeFileSync } from 'fs';
import { tmpdir } from 'os';
import { join } from 'path';
import { Repository } from '../../db/repository.js';
import { createA2aOidcProvider, mountA2aOidc } from './oidc-provider.js';
import { mountA2aRequestHandler } from './request-handler.js';
import { b64url, Client } from './__e2e-helpers.js';

/**
 * Task 8 — A2A 実行 e2e ゲート（実トークン → message/send → ジョブ実行 → Artifact）。
 *
 * Plan 1（実 oidc-provider）+ Plan 2A（consent）+ Plan 2B（mountA2aRequestHandler）を
 * 同一 http server に相乗りさせ、本物の authorization_code + PKCE フローで発行したトークンで
 * 本番 JSON-RPC `message/send` を叩く。実 Worker / 実 LLM は使わない代わりに、
 * executor が enqueue した queued ジョブを「テスト用 worker」が決定論的に succeeded へ遷移させ、
 * output/result.txt を Artifact として返す結線（A2A プロトコル層 ↔ ジョブブリッジ ↔ Artifact）を守る。
 *
 * 否定経路（Bearer 無し / 委任スコープ外スキル）も同じ実フローで fail-closed を実証する。
 */
describe('A2A exec e2e (real token → message/send → job → artifact + negatives)', () => {
  let server: http.Server;
  let base: string;
  let audience: string;
  let repo: Repository;
  let provider: Provider;

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

  const RESULT_TEXT = 'A2A round-trip artifact OK — 実行結果ファイルの中身';
  let outDir: string; // tmp workspace dir with output/result.txt
  let stopCompleter: (() => void) | null = null;

  function jobCountForUser(): number {
    const row = (repo as any).db
      .prepare("SELECT COUNT(*) AS n FROM jobs WHERE owner_id = ?")
      .get(userId) as { n: number };
    return row.n;
  }

  /**
   * テスト用 "worker": queued ジョブが現れた瞬間に succeeded へ遷移させ、
   * jobs.worktree_path と local_tasks.workspace_path の両方を outDir に向ける
   * （executor は getLocalTask(id).workspacePath ?? job.worktreePath を読むため両方を埋める）。
   * 行の生成/削除はせず status と path を更新するだけなので、ジョブを作らない否定経路には無害。
   */
  function startCompleter(): () => void {
    let stop = false;
    const db = (repo as any).db;
    const selQueued = db.prepare("SELECT id, issue_number FROM jobs WHERE status = 'queued'");
    const updJob = db.prepare("UPDATE jobs SET status = 'succeeded', worktree_path = ? WHERE id = ?");
    const updTask = db.prepare("UPDATE local_tasks SET workspace_path = ? WHERE id = ?");
    (async () => {
      while (!stop) {
        const rows = selQueued.all() as Array<{ id: string; issue_number: number }>;
        for (const r of rows) {
          updJob.run(outDir, r.id);
          updTask.run(outDir, r.issue_number);
        }
        await new Promise(res => setTimeout(res, 10));
      }
    })();
    return () => { stop = true; };
  }

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

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

    // acting user（実在が必要: computeEffectiveScope が 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）。
    // summarize は許可リストにあるが consent では委任しない → 委任外スキルの否定経路に使う。
    (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']);

    // 決定論的ジョブ完了用の出力ファイル（worker が書く成果物を模す）。
    outDir = mkdtempSync(join(tmpdir(), 'a2a-exec-ws-'));
    mkdirSync(join(outDir, 'output'), { recursive: true });
    writeFileSync(join(outDir, 'output', 'result.txt'), RESULT_TEXT);

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

    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 に、本番 request handler を /a2a にマウント。
    mountA2aOidc(app, provider, { repo, resourceAudience: audience });
    mountA2aRequestHandler(app, {
      provider, repo,
      baseUrl: base,
      issuer: `${base}/oidc`,
      version: '1.0.0-test',
    });

    stopCompleter = startCompleter();
  });

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

  /**
   * authorize → consent（space/skill を選択）→ code 取得 → token 交換まで実フローで通す。
   * consent confirm のボディは consent-api.ts が読む `selectedSpaces` / `selectedSkills`。
   */
  async function obtainToken(
    selectedSpaces: string,
    selectedSkills: string,
  ): Promise<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')!;

    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 };
    expect(tokenJson.access_token).toBeTruthy();
    return tokenJson.access_token;
  }

  function sendMessage(body: unknown, headers: Record<string, string> = {}) {
    return fetch(`${base}/a2a`, {
      method: 'POST',
      headers: { 'content-type': 'application/json', ...headers },
      body: JSON.stringify(body),
    });
  }

  function messageSendEnvelope(text: string, metadata?: Record<string, unknown>) {
    return {
      jsonrpc: '2.0',
      id: 1,
      method: 'message/send',
      params: {
        message: {
          kind: 'message',
          messageId: randomUUID(),
          role: 'user',
          parts: [{ kind: 'text', text }],
          ...(metadata ? { metadata } : {}),
        },
      },
    };
  }

  it('happy path: real token → message/send → completed Task with artifact (result.txt) + scoped job', async () => {
    const token = await obtainToken(spaceId, 'research');
    const before = jobCountForUser();

    const res = await sendMessage(
      messageSendEnvelope('do research'),
      { authorization: `Bearer ${token}` },
    );
    expect(res.status).toBe(200);
    const json = await res.json() as {
      jsonrpc: string;
      result?: {
        kind: string;
        status: { state: string };
        artifacts?: Array<{ name?: string; parts: Array<{ kind: string; text?: string }> }>;
      };
      error?: unknown;
    };

    // JSON-RPC result（error ではない）で、完了 Task が返る。
    expect(json.jsonrpc).toBe('2.0');
    expect(json.error).toBeUndefined();
    expect(json.result).toBeDefined();
    expect(json.result!.kind).toBe('task');
    expect(json.result!.status.state).toBe('completed');

    // Artifact に output/result.txt の中身が含まれる。
    const artifacts = json.result!.artifacts ?? [];
    expect(artifacts.length).toBeGreaterThan(0);
    const allText = artifacts.flatMap(a => a.parts).map(p => p.text ?? '').join('\n');
    expect(allText).toContain(RESULT_TEXT);

    // セキュリティ: acting user 名義で、委任したスペース + piece research のジョブが作られた。
    expect(jobCountForUser()).toBe(before + 1);
    const jobRow = (repo as any).db
      .prepare("SELECT owner_id, space_id, piece_name FROM jobs WHERE owner_id = ? ORDER BY created_at DESC LIMIT 1")
      .get(userId) as { owner_id: string; space_id: string; piece_name: string };
    expect(jobRow.owner_id).toBe(userId);
    expect(jobRow.space_id).toBe(spaceId);
    expect(jobRow.piece_name).toBe('research');

    // local_tasks 側も同じスコープで作られている。
    const taskRow = (repo as any).db
      .prepare("SELECT owner_id, space_id, piece_name FROM local_tasks WHERE owner_id = ? ORDER BY id DESC LIMIT 1")
      .get(userId) as { owner_id: string; space_id: string; piece_name: string };
    expect(taskRow.owner_id).toBe(userId);
    expect(taskRow.space_id).toBe(spaceId);
    expect(taskRow.piece_name).toBe('research');
  }, 30_000);

  it('no bearer → terminal failed Task, no job created', async () => {
    const before = jobCountForUser();
    const res = await sendMessage(messageSendEnvelope('do research'));
    expect(res.status).toBe(200);
    const json = await res.json() as { jsonrpc: string; error?: unknown; result?: { kind?: string; status?: { state: string } } };
    expect(json.jsonrpc).toBe('2.0');
    // 未認証 → executor が principal 無しで完全な terminal Task(state=failed) を publish する。
    // （2C-3 Task 4 で SDK ResultManager 互換に修正: 旧挙動は bare status-update が drop され error 化していた）
    expect(json.error).toBeUndefined();
    expect(json.result?.kind).toBe('task');
    expect(json.result?.status?.state).toBe('failed');
    // ジョブは作られない（最重要のセキュリティ不変条件）。
    expect(jobCountForUser()).toBe(before);
  }, 30_000);

  it('out-of-scope skill (summarize, allowlisted but not consented) → fail-closed, no job created', async () => {
    const token = await obtainToken(spaceId, 'research'); // research のみ委任
    const before = jobCountForUser();

    const res = await sendMessage(
      messageSendEnvelope('please summarize', { skillId: 'summarize' }),
      { authorization: `Bearer ${token}` },
    );
    expect(res.status).toBe(200);
    const json = await res.json() as { jsonrpc: string; error?: unknown; result?: { kind?: string; status?: { state: string } } };
    expect(json.jsonrpc).toBe('2.0');
    // selectPieceForMessage が null → executor は createLocalTask/createJob 前に完全な terminal Task(state=failed) を publish。
    expect(json.error).toBeUndefined();
    expect(json.result?.kind).toBe('task');
    expect(json.result?.status?.state).toBe('failed');
    // 委任外スキルでジョブは作られない（fail-closed）。
    expect(jobCountForUser()).toBe(before);
  }, 30_000);
});

/**
 * Task 9 — ガバナンス E2E: 同時実行数上限超過 → rejected Task（rev.1 Critical の再発防止ゲート）。
 *
 * maxConcurrentTasks=1 で mount し、該当 grant の非終端 a2a_task を DB に 1 件 seed した状態で
 * message/send を叩く。executor の concurrency チェック（tryReserveConcurrency）が
 * Math.max(dbCount=1, reserved=0) >= cap=1 を検出し、JSON-RPC result として
 * kind='task' / state='rejected' の完全な Task を返す。ジョブは作られない。
 */
describe('A2A exec e2e: governance — concurrency cap → rejected Task', () => {
  let server: http.Server;
  let base: string;
  let repo: Repository;

  const clientId = 'a2a_gov_e2e';
  const redirectUri = 'https://client.test/cb';
  const userId = 'user-gov';
  const spaceId = 'space-gov';

  function jobCountForUser(): number {
    const row = (repo as any).db
      .prepare("SELECT COUNT(*) AS n FROM jobs WHERE owner_id = ?")
      .get(userId) as { n: number };
    return row.n;
  }

  function sendMessage(body: unknown, headers: Record<string, string> = {}) {
    return fetch(`${base}/a2a`, {
      method: 'POST',
      headers: { 'content-type': 'application/json', ...headers },
      body: JSON.stringify(body),
    });
  }

  async function obtainToken(selectedSpaces: string, selectedSkills: string): Promise<string> {
    const audience = `${base}/a2a`;
    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')!;

    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_verifier: verifier, client_id: clientId, redirect_uri: redirectUri,
    });
    expect(tokenRes.status).toBe(200);
    const tokenJson = await tokenRes.json() as { access_token: string };
    expect(tokenJson.access_token).toBeTruthy();
    return tokenJson.access_token;
  }

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

    repo.createA2aClient({
      clientId, name: 'Gov E2E Client', redirectUris: [redirectUri],
      secretHash: null, tokenEndpointAuthMethod: 'none',
      agentCardUrl: null, issuer: null, keyFingerprint: null, status: 'active', createdBy: 'admin',
    });

    (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, 'ugov@test', 'Gov User');

    (repo as any).db.prepare(
      "INSERT INTO spaces (id, title, kind, owner_id, visibility) VALUES (?, 'Gov Space', 'case', ?, 'private')",
    ).run(spaceId, userId);
    repo.setSpaceA2aSkills(spaceId, ['research']);

    const secretsDir = mkdtempSync(join(tmpdir(), 'a2a-gov-e2e-'));
    const app = express();
    // 擬似ログイン: consent の req.user 用。/a2a は Bearer ベースなので無害。
    app.use((req, _res, next) => { (req as any).user = { id: userId, role: 'user' }; next(); });

    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}`;
    const audience = `${base}/a2a`;

    const govProvider = createA2aOidcProvider({
      repo, secretsDir,
      issuer: `${base}/oidc`,
      resourceAudience: audience,
      cookieKeys: ['gov-cookie-key'],
    });
    (govProvider as any).proxy = false;

    mountA2aOidc(app, govProvider, { repo, resourceAudience: audience });
    // maxConcurrentTasks=1: 非終端タスクが 1 件あれば次のリクエストは rejected になる。
    mountA2aRequestHandler(app, {
      provider: govProvider, repo,
      baseUrl: base,
      issuer: `${base}/oidc`,
      version: '1.0.0-test',
      limits: { maxConcurrentTasks: 1 },
    });
  });

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

  it('concurrency cap exceeded → rejected Task (kind=task, state=rejected), no job created', async () => {
    // Step 1: 実 OAuth フローでトークンを取得（delegation 行が DB に生成される）。
    const token = await obtainToken(spaceId, 'research');

    // Step 2: 生成された delegation の grant_id を取得。
    const delRow = (repo as any).db
      .prepare("SELECT grant_id FROM a2a_delegations WHERE user_id = ? ORDER BY rowid DESC LIMIT 1")
      .get(userId) as { grant_id: string } | undefined;
    expect(delRow?.grant_id).toBeTruthy();
    const grantId = delRow!.grant_id;

    // Step 3: 非終端 a2a_task を 1 件 seed → countNonTerminalA2aTasksByGrant が 1 を返す。
    //         maxConcurrentTasks=1 なので Math.max(dbCount=1, reserved=0) >= 1 → 予約失敗。
    repo.saveA2aTask({
      id: randomUUID(),
      contextId: null,
      jobId: null,
      localTaskId: null,
      payload: {
        id: randomUUID(),
        kind: 'task',
        contextId: 'ctx-blocker',
        status: { state: 'working', timestamp: new Date().toISOString() },
      },
      grantId,
    });

    const before = jobCountForUser();

    // Step 4: message/send — 同時実行数上限に到達済みなので rejected になるはず。
    const res = await sendMessage(
      {
        jsonrpc: '2.0',
        id: 1,
        method: 'message/send',
        params: {
          message: {
            kind: 'message',
            messageId: randomUUID(),
            role: 'user',
            parts: [{ kind: 'text', text: 'do research' }],
          },
        },
      },
      { authorization: `Bearer ${token}` },
    );
    expect(res.status).toBe(200);
    const json = await res.json() as {
      jsonrpc: string;
      error?: unknown;
      result?: { kind?: string; status?: { state: string } };
    };

    // Step 5: JSON-RPC error は返らない（SDK ResultManager 互換の完全な Task として返る）。
    //         kind=task かつ state=rejected — これが rev.1 Critical が検出できなかったアサーション。
    expect(json.jsonrpc).toBe('2.0');
    expect(json.error).toBeUndefined();
    expect(json.result?.kind).toBe('task');
    expect(json.result?.status?.state).toBe('rejected');

    // ジョブは作られない（最重要のセキュリティ不変条件）。
    expect(jobCountForUser()).toBe(before);
  }, 30_000);
});
