/**
 * Integration tests for OIDC endpoints.
 * Tests discovery document, JWKS, id_token issuance, and agent-info endpoint.
 * Uses a real Postgres test DB and Redis test instance.
 */

import crypto from 'crypto';
import request from 'supertest';
import { Application } from 'express';
import { v4 as uuidv4 } from 'uuid';
import { Pool } from 'pg';
import jwt from 'jsonwebtoken';
import { createPublicKey, JsonWebKey } from 'crypto';

// Set test environment variables before importing app
const { privateKey, publicKey } = crypto.generateKeyPairSync('rsa', {
  modulusLength: 2048,
  publicKeyEncoding: { type: 'spki', format: 'pem' },
  privateKeyEncoding: { type: 'pkcs8', format: 'pem' },
});

process.env['DATABASE_URL'] =
  process.env['TEST_DATABASE_URL'] ??
  'postgresql://sentryagent:sentryagent@localhost:5432/sentryagent_idp_test';
process.env['REDIS_URL'] = process.env['TEST_REDIS_URL'] ?? 'redis://localhost:6379/1';
process.env['JWT_PRIVATE_KEY'] = privateKey;
process.env['JWT_PUBLIC_KEY'] = publicKey;
process.env['NODE_ENV'] = 'test';
process.env['OIDC_ISSUER'] = 'https://idp.sentryagent.ai';

import { createApp } from '../../src/app';
import { signToken } from '../../src/utils/jwt';
import { closePool } from '../../src/db/pool';
import { closeRedisClient } from '../../src/cache/redis';
import { IJWKSKey, IJWKSResponse } from '../../src/types/oidc';

function makeToken(sub: string, scope = 'agents:read agents:write tokens:read'): string {
  return signToken({ sub, client_id: sub, scope, jti: uuidv4() }, privateKey);
}

/**
 * Converts a JWK public key to a PEM string for jwt.verify.
 */
function jwkToPem(jwk: IJWKSKey): string {
  const keyObj = createPublicKey({ key: jwk as unknown as JsonWebKey, format: 'jwk' });
  return keyObj.export({ type: 'spki', format: 'pem' }) as string;
}

describe('OIDC Integration Tests', () => {
  let app: Application;
  let pool: Pool;

  beforeAll(async () => {
    app = await createApp();
    pool = new Pool({ connectionString: process.env['DATABASE_URL'] });

    // Ensure all required tables exist for this test suite
    const migrations = [
      `CREATE TABLE IF NOT EXISTS organizations (
        organization_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
        name VARCHAR(255) NOT NULL,
        slug VARCHAR(128) NOT NULL UNIQUE,
        plan_tier VARCHAR(32) NOT NULL DEFAULT 'free',
        max_agents INTEGER NOT NULL DEFAULT 10,
        max_tokens_per_month INTEGER NOT NULL DEFAULT 10000,
        status VARCHAR(16) NOT NULL DEFAULT 'active',
        created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
        updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
      )`,
      `CREATE TABLE IF NOT EXISTS agents (
        agent_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
        organization_id UUID NOT NULL DEFAULT 'a0000000-0000-0000-0000-000000000000',
        email VARCHAR(255) NOT NULL UNIQUE,
        agent_type VARCHAR(32) NOT NULL,
        version VARCHAR(64) NOT NULL,
        capabilities TEXT[] NOT NULL DEFAULT '{}',
        owner VARCHAR(128) NOT NULL,
        deployment_env VARCHAR(16) NOT NULL,
        status VARCHAR(24) NOT NULL DEFAULT 'active',
        did VARCHAR(512),
        did_created_at TIMESTAMPTZ,
        created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
        updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
      )`,
      `CREATE TABLE IF NOT EXISTS credentials (
        credential_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
        client_id UUID NOT NULL,
        organization_id UUID NOT NULL DEFAULT 'a0000000-0000-0000-0000-000000000000',
        secret_hash VARCHAR(255) NOT NULL,
        vault_path VARCHAR(512),
        status VARCHAR(16) NOT NULL DEFAULT 'active',
        created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
        expires_at TIMESTAMPTZ,
        revoked_at TIMESTAMPTZ
      )`,
      `CREATE TABLE IF NOT EXISTS audit_events (
        event_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
        agent_id UUID NOT NULL,
        action VARCHAR(64) NOT NULL,
        outcome VARCHAR(16) NOT NULL,
        ip_address VARCHAR(64) NOT NULL,
        user_agent TEXT NOT NULL,
        metadata JSONB NOT NULL DEFAULT '{}',
        timestamp TIMESTAMPTZ NOT NULL DEFAULT NOW()
      )`,
      `CREATE TABLE IF NOT EXISTS token_revocations (
        jti UUID PRIMARY KEY,
        expires_at TIMESTAMPTZ NOT NULL,
        revoked_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
      )`,
      `CREATE TABLE IF NOT EXISTS token_monthly_counts (
        client_id UUID NOT NULL,
        month_key VARCHAR(7) NOT NULL,
        count INTEGER NOT NULL DEFAULT 0,
        PRIMARY KEY (client_id, month_key)
      )`,
      `CREATE TABLE IF NOT EXISTS oidc_keys (
        id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
        kid VARCHAR(64) NOT NULL UNIQUE,
        algorithm VARCHAR(16) NOT NULL,
        public_key_jwk JSONB NOT NULL,
        vault_key_path VARCHAR(512) NOT NULL,
        is_current BOOLEAN NOT NULL DEFAULT FALSE,
        created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
        expires_at TIMESTAMPTZ NOT NULL
      )`,
    ];

    for (const sql of migrations) {
      await pool.query(sql);
    }
  });

  afterEach(async () => {
    await pool.query('DELETE FROM audit_events');
    await pool.query('DELETE FROM token_revocations');
    await pool.query('DELETE FROM token_monthly_counts');
    await pool.query('DELETE FROM credentials');
    await pool.query('DELETE FROM agents');
    // Do NOT delete oidc_keys — ensureCurrentKey() runs once at app startup
  });

  afterAll(async () => {
    await pool.query('DELETE FROM oidc_keys');
    await pool.end();
    await closePool();
    await closeRedisClient();
  });

  async function createAgentWithCredentials(): Promise<{
    agentId: string;
    clientSecret: string;
    orgId: string;
  }> {
    const agentId = uuidv4();
    const orgId = 'a0000000-0000-0000-0000-000000000000';
    const token = makeToken(agentId, 'agents:read agents:write tokens:read');

    // Insert agent directly
    await pool.query(
      `INSERT INTO agents
         (agent_id, organization_id, email, agent_type, version, capabilities, owner, deployment_env, status)
       VALUES ($1, $2, $3, 'screener', '1.0.0', '{"agents:read"}', 'test-team', 'production', 'active')`,
      [agentId, orgId, `oidc-test-${agentId}@test.ai`],
    );

    // Generate credential via API
    const credRes = await request(app)
      .post(`/api/v1/agents/${agentId}/credentials`)
      .set('Authorization', `Bearer ${token}`)
      .send({});

    return { agentId, clientSecret: credRes.body.clientSecret, orgId };
  }

  // ── Discovery document ───────────────────────────────────────────────────

  describe('GET /.well-known/openid-configuration', () => {
    it('returns a valid OIDC discovery document', async () => {
      const res = await request(app).get('/.well-known/openid-configuration');

      expect(res.status).toBe(200);
      expect(res.body.issuer).toBe('https://idp.sentryagent.ai');
      expect(res.body.token_endpoint).toContain('/oauth2/token');
      expect(res.body.jwks_uri).toContain('/.well-known/jwks.json');
      expect(res.body.authorization_endpoint).toBeDefined();
    });

    it('includes all required OIDC discovery fields', async () => {
      const res = await request(app).get('/.well-known/openid-configuration');

      const requiredFields = [
        'issuer',
        'authorization_endpoint',
        'token_endpoint',
        'jwks_uri',
        'response_types_supported',
        'subject_types_supported',
        'id_token_signing_alg_values_supported',
        'scopes_supported',
        'claims_supported',
        'grant_types_supported',
      ];

      for (const field of requiredFields) {
        expect(res.body).toHaveProperty(field);
      }
    });

    it('does not require authentication', async () => {
      const res = await request(app).get('/.well-known/openid-configuration');
      expect(res.status).toBe(200);
    });

    it('includes openid in scopes_supported', async () => {
      const res = await request(app).get('/.well-known/openid-configuration');
      expect(res.body.scopes_supported).toContain('openid');
    });

    it('includes RS256 in id_token_signing_alg_values_supported', async () => {
      const res = await request(app).get('/.well-known/openid-configuration');
      expect(res.body.id_token_signing_alg_values_supported).toContain('RS256');
    });
  });

  // ── JWKS endpoint ─────────────────────────────────────────────────────────

  describe('GET /.well-known/jwks.json', () => {
    it('returns JWKS with at least one key', async () => {
      const res = await request(app).get('/.well-known/jwks.json');

      expect(res.status).toBe(200);
      expect(res.body.keys).toBeInstanceOf(Array);
      expect(res.body.keys.length).toBeGreaterThanOrEqual(1);
    });

    it('returns keys with required JWK fields', async () => {
      const res = await request(app).get('/.well-known/jwks.json');
      const key = res.body.keys[0];

      expect(key.kid).toBeDefined();
      expect(key.kty).toBeDefined();
      expect(key.use).toBe('sig');
      expect(key.alg).toBeDefined();
    });

    it('does not require authentication', async () => {
      const res = await request(app).get('/.well-known/jwks.json');
      expect(res.status).toBe(200);
    });

    it('sets Cache-Control: public, max-age=3600', async () => {
      const res = await request(app).get('/.well-known/jwks.json');
      expect(res.headers['cache-control']).toContain('public');
      expect(res.headers['cache-control']).toContain('max-age=3600');
    });
  });

  // ── Token endpoint with openid scope ─────────────────────────────────────

  describe('POST /api/v1/token with openid scope', () => {
    it('returns id_token when openid scope is requested', async () => {
      const { agentId, clientSecret } = await createAgentWithCredentials();

      const res = await request(app)
        .post('/api/v1/token')
        .type('form')
        .send({
          grant_type: 'client_credentials',
          client_id: agentId,
          client_secret: clientSecret,
          scope: 'openid agents:read',
        });

      expect(res.status).toBe(200);
      expect(res.body.id_token).toBeDefined();
      expect(typeof res.body.id_token).toBe('string');
      expect(res.body.id_token.split('.')).toHaveLength(3);
    });

    it('does not return id_token when openid scope is not requested', async () => {
      const { agentId, clientSecret } = await createAgentWithCredentials();

      const res = await request(app)
        .post('/api/v1/token')
        .type('form')
        .send({
          grant_type: 'client_credentials',
          client_id: agentId,
          client_secret: clientSecret,
          scope: 'agents:read',
        });

      expect(res.status).toBe(200);
      expect(res.body.id_token).toBeUndefined();
    });

    it('id_token is verifiable against JWKS from /.well-known/jwks.json', async () => {
      const { agentId, clientSecret } = await createAgentWithCredentials();

      // Issue token with openid scope
      const tokenRes = await request(app)
        .post('/api/v1/token')
        .type('form')
        .send({
          grant_type: 'client_credentials',
          client_id: agentId,
          client_secret: clientSecret,
          scope: 'openid agents:read',
        });

      expect(tokenRes.status).toBe(200);
      const idToken: string = tokenRes.body.id_token;

      // Fetch JWKS
      const jwksRes = await request(app).get('/.well-known/jwks.json');
      const jwks: IJWKSResponse = jwksRes.body;

      // Decode header to get kid
      const decoded = jwt.decode(idToken, { complete: true });
      expect(decoded).not.toBeNull();
      const kid = decoded!.header.kid;

      // Find matching key
      const matchingKey = jwks.keys.find((k) => k.kid === kid);
      expect(matchingKey).toBeDefined();

      // Verify signature
      const publicKeyPem = jwkToPem(matchingKey!);
      const verified = jwt.verify(idToken, publicKeyPem, { algorithms: ['RS256', 'ES256'] });
      expect(verified).toBeDefined();
      const payload = verified as Record<string, unknown>;
      expect(payload['sub']).toBe(agentId);
    });

    it('id_token contains correct agent claims', async () => {
      const { agentId, clientSecret } = await createAgentWithCredentials();

      const res = await request(app)
        .post('/api/v1/token')
        .type('form')
        .send({
          grant_type: 'client_credentials',
          client_id: agentId,
          client_secret: clientSecret,
          scope: 'openid agents:read',
        });

      const idToken: string = res.body.id_token;
      const decoded = jwt.decode(idToken) as Record<string, unknown>;

      expect(decoded['sub']).toBe(agentId);
      expect(decoded['iss']).toBe('https://idp.sentryagent.ai');
      expect(decoded['aud']).toBe(agentId);
      expect(decoded['agent_type']).toBe('screener');
      expect(decoded['deployment_env']).toBe('production');
      expect(decoded['organization_id']).toBeDefined();
    });

    it('id_token header contains kid matching the JWKS', async () => {
      const { agentId, clientSecret } = await createAgentWithCredentials();

      const tokenRes = await request(app)
        .post('/api/v1/token')
        .type('form')
        .send({
          grant_type: 'client_credentials',
          client_id: agentId,
          client_secret: clientSecret,
          scope: 'openid agents:read',
        });

      const jwksRes = await request(app).get('/.well-known/jwks.json');
      const jwks: IJWKSResponse = jwksRes.body;
      const jwksKids = jwks.keys.map((k) => k.kid);

      const decoded = jwt.decode(tokenRes.body.id_token, { complete: true });
      expect(jwksKids).toContain(decoded!.header.kid);
    });
  });

  // ── Agent info endpoint ───────────────────────────────────────────────────

  describe('GET /agent-info', () => {
    it('returns agent identity claims for authenticated caller', async () => {
      const { agentId } = await createAgentWithCredentials();
      const token = makeToken(agentId, 'openid agents:read');

      const res = await request(app)
        .get('/agent-info')
        .set('Authorization', `Bearer ${token}`);

      expect(res.status).toBe(200);
      expect(res.body.sub).toBe(agentId);
      expect(res.body.agent_type).toBe('screener');
      expect(res.body.deployment_env).toBe('production');
      expect(res.body.organization_id).toBeDefined();
    });

    it('returns 401 without a Bearer token', async () => {
      const res = await request(app).get('/agent-info');
      expect(res.status).toBe(401);
    });

    it('returns 401 with an invalid Bearer token', async () => {
      const res = await request(app)
        .get('/agent-info')
        .set('Authorization', 'Bearer invalid.token.here');
      expect(res.status).toBe(401);
    });

    it('includes scope in the agent-info response', async () => {
      const { agentId } = await createAgentWithCredentials();
      const token = makeToken(agentId, 'openid agents:read');

      const res = await request(app)
        .get('/agent-info')
        .set('Authorization', `Bearer ${token}`);

      expect(res.status).toBe(200);
      expect(res.body.scope).toContain('openid');
    });

    it('returns 404 for a token referencing a non-existent agent', async () => {
      const unknownAgentId = uuidv4();
      const token = makeToken(unknownAgentId, 'openid agents:read');

      const res = await request(app)
        .get('/agent-info')
        .set('Authorization', `Bearer ${token}`);

      expect(res.status).toBe(404);
    });
  });
});
