// @vitest-environment node
/**
 * AuthGatedRequestHandler — unit + E2E tests.
 *
 * What is tested:
 *   - getTask / cancelTask / resubscribe reject UnauthenticatedUser (or missing context)
 *     with A2AError code -32600 (INVALID_REQUEST).
 *   - Authenticated contexts pass the auth gate and reach the underlying store
 *     (which throws taskNotFound -32001, not the auth error).
 *   - E2E via supertest: unauthenticated `tasks/get` HTTP request → proper JSON-RPC
 *     error response (not 500).
 *
 * Test approach: unit-test AuthGatedRequestHandler directly with InMemoryTaskStore
 * and a stub executor (fastest, no HTTP round-trip needed for unit assertions).
 * One E2E supertest case covers the full HTTP path.
 */
import { describe, it, expect, vi } from 'vitest';
import express from 'express';
import request from 'supertest';
import {
  A2AError,
  DefaultRequestHandler,
  InMemoryTaskStore,
  ServerCallContext,
  UnauthenticatedUser,
} from '@a2a-js/sdk/server';
import { Repository } from '../../db/repository.js';
import { buildBaseCard } from './agent-card.js';
import { AuthGatedRequestHandler, mountA2aRequestHandler } from './request-handler.js';
import { A2aAuthenticatedUser } from './user-builder.js';
import type { A2aPrincipal } from './token-auth.js';
import { A2aLimiter, resolveA2aLimits } from './limiter.js';

// ─── fixtures ─────────────────────────────────────────────────────────────────

const BASE_DEPS = { baseUrl: 'https://m', issuer: 'https://m/oidc', version: '1.0.0' };

const FAKE_PRINCIPAL: A2aPrincipal = {
  actingUserId: 'u1', clientId: 'cli1', grantId: 'g1',
  delegation: {
    id: 'd1', userId: 'u1', clientId: 'cli1', grantId: 'g1',
    grantedSpaceIds: ['s1'], grantedSkills: ['research'],
    expiresAt: null, revokedAt: null,
  },
};

/** Helper — construct AuthGatedRequestHandler with an in-memory store. */
function makeHandler() {
  const store = new InMemoryTaskStore();
  const fakeExecutor = { execute: async () => {} } as any;
  const baseCard = buildBaseCard(BASE_DEPS);
  return new AuthGatedRequestHandler(baseCard, store, fakeExecutor);
}

const UNAUTH_CTX = new ServerCallContext(undefined, new UnauthenticatedUser());
const AUTH_CTX   = new ServerCallContext(undefined, new A2aAuthenticatedUser(FAKE_PRINCIPAL));

// ─── getTask ──────────────────────────────────────────────────────────────────

describe('AuthGatedRequestHandler.getTask', () => {
  it('rejects UnauthenticatedUser with A2AError', async () => {
    const h = makeHandler();
    await expect(h.getTask({ id: 'x' }, UNAUTH_CTX)).rejects.toBeInstanceOf(A2AError);
  });

  it('rejected error carries INVALID_REQUEST code (-32600)', async () => {
    const h = makeHandler();
    await expect(h.getTask({ id: 'x' }, UNAUTH_CTX))
      .rejects.toMatchObject({ code: -32600 });
  });

  it('fail-closed: undefined context also rejects', async () => {
    const h = makeHandler();
    await expect(h.getTask({ id: 'x' }, undefined)).rejects.toMatchObject({ code: -32600 });
  });

  it('authenticated context passes auth gate (taskNotFound, not auth error)', async () => {
    const h = makeHandler();
    const err = await h.getTask({ id: 'nonexistent' }, AUTH_CTX).catch(e => e);
    expect(err).toBeInstanceOf(A2AError);
    // -32001 = TASK_NOT_FOUND (auth gate was cleared, underlying store was reached)
    expect(err.code).not.toBe(-32600);
  });
});

// ─── cancelTask ───────────────────────────────────────────────────────────────

describe('AuthGatedRequestHandler.cancelTask', () => {
  it('rejects UnauthenticatedUser with INVALID_REQUEST (-32600)', async () => {
    const h = makeHandler();
    await expect(h.cancelTask({ id: 'x' }, UNAUTH_CTX))
      .rejects.toMatchObject({ code: -32600 });
  });

  it('fail-closed: undefined context rejects', async () => {
    const h = makeHandler();
    await expect(h.cancelTask({ id: 'x' }, undefined)).rejects.toMatchObject({ code: -32600 });
  });

  it('authenticated context passes auth gate (taskNotFound, not auth error)', async () => {
    const h = makeHandler();
    const err = await h.cancelTask({ id: 'nonexistent' }, AUTH_CTX).catch(e => e);
    expect(err).toBeInstanceOf(A2AError);
    expect(err.code).not.toBe(-32600);
  });
});

// ─── resubscribe ──────────────────────────────────────────────────────────────

describe('AuthGatedRequestHandler.resubscribe', () => {
  it('rejects UnauthenticatedUser synchronously (throws before returning generator)', () => {
    const h = makeHandler();
    expect(() => h.resubscribe({ id: 'x' }, UNAUTH_CTX)).toThrow(A2AError);
  });

  it('thrown error carries INVALID_REQUEST code (-32600)', () => {
    const h = makeHandler();
    let caught: unknown;
    try { h.resubscribe({ id: 'x' }, UNAUTH_CTX); } catch (e) { caught = e; }
    expect(caught).toBeInstanceOf(A2AError);
    expect((caught as A2AError).code).toBe(-32600);
  });

  it('fail-closed: undefined context throws synchronously', () => {
    const h = makeHandler();
    expect(() => h.resubscribe({ id: 'x' }, undefined)).toThrow(A2AError);
  });

  it('authenticated context returns an AsyncGenerator without throwing', () => {
    const h = makeHandler();
    let gen: ReturnType<typeof h.resubscribe> | undefined;
    expect(() => { gen = h.resubscribe({ id: 'x' }, AUTH_CTX); }).not.toThrow();
    expect(gen).toBeDefined();
    expect(typeof gen![Symbol.asyncIterator]).toBe('function');
    gen!.return?.(undefined); // clean up
  });
});

// ─── Rate limiting + resubscribe slot cap (Task 7) ────────────────────────────

describe('AuthGatedRequestHandler — rate limiting', () => {
  function makeHandlerWithLimiter(limiter: A2aLimiter) {
    const store = new InMemoryTaskStore();
    const fakeExecutor = { execute: async () => {} } as any;
    const baseCard = buildBaseCard(BASE_DEPS);
    return new AuthGatedRequestHandler(baseCard, store, fakeExecutor, undefined, limiter);
  }

  it('getTask: second call past rate=1 throws A2AError (rate limit exceeded)', async () => {
    const limiter = new A2aLimiter(resolveA2aLimits({ ratePerMinute: 1 }));
    const h = makeHandlerWithLimiter(limiter);
    // First call: auth OK, 1 rate token consumed → falls through to taskNotFound (not rate error)
    await h.getTask({ id: 'x' }, AUTH_CTX).catch(() => {});
    // Second call: no token left → rate-limit error
    const err = await h.getTask({ id: 'x' }, AUTH_CTX).catch(e => e);
    expect(err).toBeInstanceOf(A2AError);
    expect(err.code).toBe(-32600);
    expect(err.message).toMatch(/rate limit/);
  });

  it('getTask: unauthenticated throws auth error even when budget is fully exhausted', async () => {
    const limiter = new A2aLimiter(resolveA2aLimits({ ratePerMinute: 1 }));
    // Pre-drain the bucket for grant g1 (the FAKE_PRINCIPAL's grantId)
    limiter.tryConsumeRate('g1');
    const h = makeHandlerWithLimiter(limiter);
    // Auth check must fire before rate check; message must be 'unauthorized', not 'rate limit'
    const err = await h.getTask({ id: 'x' }, UNAUTH_CTX).catch(e => e);
    expect(err).toBeInstanceOf(A2AError);
    expect(err.message).toMatch(/unauthorized/);
  });

  it('cancelTask: second call past rate=1 throws A2AError (rate limit exceeded)', async () => {
    const limiter = new A2aLimiter(resolveA2aLimits({ ratePerMinute: 1 }));
    const h = makeHandlerWithLimiter(limiter);
    // First call: auth OK, 1 rate token consumed → falls through to taskNotFound (not rate error)
    await h.cancelTask({ id: 'x' }, AUTH_CTX).catch(() => {});
    // Second call: no token left → rate-limit error
    const err = await h.cancelTask({ id: 'x' }, AUTH_CTX).catch(e => e);
    expect(err).toBeInstanceOf(A2AError);
    expect(err.code).toBe(-32600);
    expect(err.message).toMatch(/rate limit/);
  });
});

describe('AuthGatedRequestHandler — resubscribe slot cap (Task 7)', () => {
  function makeHandlerWithLimiter(limiter: A2aLimiter) {
    const store = new InMemoryTaskStore();
    const fakeExecutor = { execute: async () => {} } as any;
    const baseCard = buildBaseCard(BASE_DEPS);
    return new AuthGatedRequestHandler(baseCard, store, fakeExecutor, undefined, limiter);
  }

  it('resubscribe: second call throws when slot cap (maxConcurrentResubscribe=1) is reached', () => {
    const limiter = new A2aLimiter(resolveA2aLimits({ maxConcurrentResubscribe: 1, ratePerMinute: 100 }));
    const h = makeHandlerWithLimiter(limiter);
    // First resubscribe: slot acquired
    const gen1 = h.resubscribe({ id: 'x' }, AUTH_CTX);
    expect(gen1).toBeDefined();
    // Second resubscribe: slot full → synchronous throw
    expect(() => h.resubscribe({ id: 'x' }, AUTH_CTX)).toThrow(A2AError);
    gen1.return?.(undefined); // clean up (async; we don't await)
  });

  it('resubscribe: slot released via finally when generator is iterated and exhausted', async () => {
    const limiter = new A2aLimiter(resolveA2aLimits({ maxConcurrentResubscribe: 1, ratePerMinute: 100 }));
    const h = makeHandlerWithLimiter(limiter);
    // Acquire the slot
    const gen1 = h.resubscribe({ id: 'x' }, AUTH_CTX);
    // Start the generator — inner store will throw (task not found), triggering finally
    await gen1.next().catch(() => {});
    // Slot should now be released; a new resubscribe must not throw
    let gen2: AsyncGenerator<any> | undefined;
    expect(() => { gen2 = h.resubscribe({ id: 'x' }, AUTH_CTX); }).not.toThrow();
    expect(gen2).toBeDefined();
    await gen2!.next().catch(() => {}); // clean up
  });
});

// ─── maxStreamSeconds fires for a quiet stream (Task 7 review fix) ───────────

describe('AuthGatedRequestHandler — maxStreamSeconds wall-clock race', () => {
  it('quiet stream (no events) completes and releases slot at deadline', async () => {
    // Use a fractional maxStreamSeconds (0.05 = 50 ms) for a fast test.
    // A2aLimiter accepts raw limits directly — bypassing resolveA2aLimits integer≥1 constraint
    // is intentional here so we don't need to wait a full second.
    const limiter = new A2aLimiter({
      ratePerMinute: 100,
      maxConcurrentTasks: 5,
      maxPayloadBytes: 65536,
      maxStreamSeconds: 0.05, // 50 ms deadline
      skillBudgetPerHour: 100,
      maxConcurrentResubscribe: 1,
    });
    const store = new InMemoryTaskStore();
    const fakeExecutor = { execute: async () => {} } as any;
    const baseCard = buildBaseCard(BASE_DEPS);
    const h = new AuthGatedRequestHandler(baseCard, store, fakeExecutor, undefined, limiter);

    // Stub super.resubscribe with a generator that never emits events (quiet live stream).
    // This is the exact scenario the timer-race fixes: the old for-await loop would
    // hold the slot indefinitely; the new Promise.race releases it at the deadline.
    const neverYielding: AsyncGenerator<any, void, undefined> = {
      [Symbol.asyncIterator]() { return this; },
      next: () => new Promise<never>(() => {}), // blocks indefinitely
      return: async (v: any) => ({ done: true as const, value: v }),
      throw: async (e: any): Promise<never> => { throw e; },
    };
    const spy = vi.spyOn(DefaultRequestHandler.prototype, 'resubscribe').mockReturnValue(
      neverYielding as any,
    );

    try {
      // Slot cap=1: acquire it
      const gen = h.resubscribe({ id: 'x' }, AUTH_CTX);
      // Kick the generator — suspends inside the 50 ms Promise.race
      const firstNext = gen.next();
      // Wait 120 ms (real timers) — the 50 ms deadline fires, race resolves with 'timeout'
      await new Promise(res => setTimeout(res, 120));
      // Generator must have completed via the timeout path
      const result = await firstNext;
      expect(result.done).toBe(true);
      // Slot must be released — second resubscribe must not throw
      let gen2: AsyncGenerator<any> | undefined;
      expect(() => { gen2 = h.resubscribe({ id: 'x' }, AUTH_CTX); }).not.toThrow();
      expect(gen2).toBeDefined();
      await gen2!.return?.(undefined); // cleanup
    } finally {
      spy.mockRestore();
    }
  }, 2000); // generous timeout for the real-timer wait

  it('quiet REAL async generator: deadline still releases slot even though inner .return() never settles', async () => {
    // Regression guard for the follow-up fix: a REAL async generator suspended inside its
    // body (not at a yield) queues a .return() behind the still-pending .next(), so the
    // return never settles for a quiet stream. The old `await it.return()` would hang the
    // wrapper forever (never reaching finally → slot leak). The stub in the test above hid
    // this because its hand-rolled `return` resolved immediately. Here we use a genuine
    // async generator to reproduce the queuing semantics.
    const limiter = new A2aLimiter({
      ratePerMinute: 100,
      maxConcurrentTasks: 5,
      maxPayloadBytes: 65536,
      maxStreamSeconds: 0.05, // 50 ms deadline
      skillBudgetPerHour: 100,
      maxConcurrentResubscribe: 1,
    });
    const store = new InMemoryTaskStore();
    const fakeExecutor = { execute: async () => {} } as any;
    const baseCard = buildBaseCard(BASE_DEPS);
    const h = new AuthGatedRequestHandler(baseCard, store, fakeExecutor, undefined, limiter);

    // A real async generator that suspends forever inside its body. Calling .return() while
    // the .next() promise is pending queues the return; it will not settle for this stream.
    async function* realQuiet(): AsyncGenerator<any, void, undefined> {
      await new Promise<void>(() => {}); // never resolves
      yield 1; // unreachable
    }
    const spy = vi.spyOn(DefaultRequestHandler.prototype, 'resubscribe').mockReturnValue(
      realQuiet() as any,
    );

    try {
      const gen = h.resubscribe({ id: 'x' }, AUTH_CTX);
      const firstNext = gen.next();
      await new Promise(res => setTimeout(res, 120)); // 50 ms deadline fires
      // Must complete via the timeout path despite the inner return never settling.
      const result = await firstNext;
      expect(result.done).toBe(true);
      // Slot released → a fresh resubscribe must not throw.
      let gen2: AsyncGenerator<any> | undefined;
      expect(() => { gen2 = h.resubscribe({ id: 'x' }, AUTH_CTX); }).not.toThrow();
      await gen2!.return?.(undefined);
    } finally {
      spy.mockRestore();
    }
  }, 2000);
});

// ─── fail-closed: authenticated but missing grantId is denied when metering is active ──

describe('AuthGatedRequestHandler — fail-closed on missing grantId', () => {
  const NO_GRANT_PRINCIPAL = { ...FAKE_PRINCIPAL, grantId: undefined } as any;
  const NO_GRANT_CTX = new ServerCallContext(undefined, new A2aAuthenticatedUser(NO_GRANT_PRINCIPAL));

  function makeHandlerWithLimiter() {
    const store = new InMemoryTaskStore();
    const fakeExecutor = { execute: async () => {} } as any;
    const baseCard = buildBaseCard(BASE_DEPS);
    const limiter = new A2aLimiter(resolveA2aLimits({ ratePerMinute: 100, maxConcurrentResubscribe: 5 }));
    return new AuthGatedRequestHandler(baseCard, store, fakeExecutor, undefined, limiter);
  }

  it('getTask denies an authenticated caller with no grantId (limiter active)', async () => {
    const h = makeHandlerWithLimiter();
    await expect(h.getTask({ id: 'x' }, NO_GRANT_CTX)).rejects.toMatchObject({ code: -32600 });
  });

  it('cancelTask denies an authenticated caller with no grantId (limiter active)', async () => {
    const h = makeHandlerWithLimiter();
    await expect(h.cancelTask({ id: 'x' }, NO_GRANT_CTX)).rejects.toMatchObject({ code: -32600 });
  });

  it('resubscribe denies an authenticated caller with no grantId (limiter active)', () => {
    const h = makeHandlerWithLimiter();
    expect(() => h.resubscribe({ id: 'x' }, NO_GRANT_CTX)).toThrow(A2AError);
  });

  it('without a limiter, a missing grantId is unmetered (reaches the store, not denied)', async () => {
    // No limiter → metering off → must not fail-closed. Store throws taskNotFound (-32001),
    // proving the auth+grant gate was passed rather than denied for the missing grant.
    const h = makeHandler();
    await expect(h.getTask({ id: 'x' }, NO_GRANT_CTX)).rejects.toMatchObject({ code: -32001 });
  });
});

// ─── E2E: tasks/get via HTTP → JSON-RPC error when unauthenticated ───────────

describe('mountA2aRequestHandler – tasks/get auth gate (E2E)', () => {
  function seedRepo(repo: Repository) {
    (repo as any).db.prepare(
      "INSERT INTO users (id, email, name, avatar_url, role, status, created_at, updated_at) " +
      "VALUES ('u1','u1@test','User One',NULL,'user','active','2026-01-01','2026-01-01')",
    ).run();
  }

  function fakeProvider() {
    return { AccessToken: { async find(_v: string) { return undefined; } } } as any;
  }

  it('unauthenticated tasks/get returns JSON-RPC error (not 500, code=-32600)', async () => {
    const repo = new Repository(':memory:');
    seedRepo(repo);
    const app = express();
    mountA2aRequestHandler(app, { provider: fakeProvider(), repo, ...BASE_DEPS });

    const res = await request(app)
      .post('/a2a')
      .set('Content-Type', 'application/json')
      .send({ jsonrpc: '2.0', id: 99, method: 'tasks/get', params: { id: 'some-task-id' } });

    expect(res.status).not.toBe(500);
    expect(res.headers['content-type']).toMatch(/json/);
    expect(res.body.jsonrpc).toBe('2.0');
    expect(res.body).toHaveProperty('error');
    expect(res.body.error.code).toBe(-32600); // INVALID_REQUEST = unauthorized
  });
});
