/**
 * Tests for the OIDC authentication provider.
 *
 * This is the code that decides whether a stranger is who they claim to be, so
 * the cases below are mostly about what it must **refuse**. A provider that
 * accepts a token it should not is not a bug you find in staging.
 *
 * Keys are generated per test and the JWKS is served from a local HTTP server,
 * so these run offline and deterministically — no network, no fixtures that
 * expire.
 */
import { strict as assert } from 'node:assert';
import { after, before, describe, it } from 'node:test';
import { createServer, type Server } from 'node:http';
import type { AddressInfo } from 'node:net';
import { SignJWT, exportJWK, generateKeyPair, type JWK } from 'jose';
import { createOidcAuthProvider, createConfiguredAuthProvider } from '../src/lib/auth-provider';

let server: Server;
let origin: string;
// Derived from jose rather than referencing the DOM `CryptoKey` type, which
// is not in this package's type lib.
type SigningKey = Awaited<ReturnType<typeof generateKeyPair>>['privateKey'];

let privateKey: SigningKey;
let otherPrivateKey: SigningKey;
let jwks: { keys: JWK[] };
/** Flipped per test to exercise discovery failures. */
let discoveryStatus = 200;
let serveDiscovery = true;

before(async () => {
  const pair = await generateKeyPair('RS256');
  const other = await generateKeyPair('RS256');
  privateKey = pair.privateKey;
  otherPrivateKey = other.privateKey;

  const publicJwk = await exportJWK(pair.publicKey);
  publicJwk.kid = 'test-key';
  publicJwk.alg = 'RS256';
  jwks = { keys: [publicJwk] };

  server = createServer((req, res) => {
    if (req.url === '/.well-known/openid-configuration') {
      if (!serveDiscovery || discoveryStatus !== 200) {
        res.writeHead(discoveryStatus === 200 ? 404 : discoveryStatus);
        res.end('{}');
        return;
      }
      res.writeHead(200, { 'content-type': 'application/json' });
      res.end(JSON.stringify({ issuer: origin, jwks_uri: `${origin}/jwks` }));
      return;
    }
    if (req.url === '/jwks') {
      res.writeHead(200, { 'content-type': 'application/json' });
      res.end(JSON.stringify(jwks));
      return;
    }
    res.writeHead(404);
    res.end();
  });

  await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve));
  origin = `http://127.0.0.1:${(server.address() as AddressInfo).port}`;
});

after(() => server?.close());

async function mint(
  claims: Record<string, unknown>,
  opts: { key?: SigningKey; issuer?: string; expiresIn?: string } = {},
) {
  return new SignJWT(claims)
    .setProtectedHeader({ alg: 'RS256', kid: 'test-key' })
    .setIssuedAt()
    .setIssuer(opts.issuer ?? origin)
    .setExpirationTime(opts.expiresIn ?? '5m')
    .sign(opts.key ?? privateKey);
}

describe('OIDC provider — what it accepts', () => {
  it('verifies a well-formed token and returns subject and email', async () => {
    const provider = createOidcAuthProvider({ issuer: origin, audience: 'pig' });
    const token = await mint({ sub: 'user-1', email: 'Person@Example.com', aud: 'pig' });
    const identity = await provider.verifyAccessToken(token);

    assert.equal(identity.subject, 'user-1');
    // Lower-cased, because PIG keys membership on the address and
    // Person@ and person@ are the same person.
    assert.equal(identity.email, 'person@example.com');
  });

  it('discovers the JWKS from the issuer when no explicit URI is given', async () => {
    const provider = createOidcAuthProvider({ issuer: origin, audience: 'pig' });
    const token = await mint({ sub: 'user-2', email: 'a@b.com', aud: 'pig' });
    assert.equal((await provider.verifyAccessToken(token)).subject, 'user-2');
  });

  it('skips discovery entirely when the JWKS URI is configured', async () => {
    // The air-gapped path: no discovery request is made at all.
    serveDiscovery = false;
    try {
      const provider = createOidcAuthProvider({
        issuer: origin,
        jwksUri: `${origin}/jwks`,
        audience: 'pig',
      });
      const token = await mint({ sub: 'user-3', email: 'a@b.com', aud: 'pig' });
      assert.equal((await provider.verifyAccessToken(token)).subject, 'user-3');
    } finally {
      serveDiscovery = true;
    }
  });

  it('falls back through email claims for providers that do not send `email`', async () => {
    const provider = createOidcAuthProvider({ issuer: origin, audience: 'pig' });
    const token = await mint({ sub: 'user-4', preferred_username: 'someone@corp.com', aud: 'pig' });
    assert.equal((await provider.verifyAccessToken(token)).email, 'someone@corp.com');
  });

  it('ignores a preferred_username that is not an address', async () => {
    // A bare username must never become an account identity — PIG keys
    // membership on the email, and "jsmith" is not one.
    const provider = createOidcAuthProvider({ issuer: origin, audience: 'pig' });
    const token = await mint({ sub: 'user-5', preferred_username: 'jsmith', aud: 'pig' });
    assert.equal((await provider.verifyAccessToken(token)).email, undefined);
  });
});

describe('OIDC provider — what it must refuse', () => {
  const provider = () => createOidcAuthProvider({ issuer: origin, audience: 'pig' });

  it('rejects a token signed by a different key', async () => {
    const token = await mint({ sub: 'x', aud: 'pig' }, { key: otherPrivateKey });
    await assert.rejects(() => provider().verifyAccessToken(token));
  });

  it('rejects a token from a different issuer', async () => {
    const token = await mint({ sub: 'x', aud: 'pig' }, { issuer: 'https://evil.example' });
    await assert.rejects(() => provider().verifyAccessToken(token));
  });

  it('rejects a token minted for a DIFFERENT application in the same tenant', async () => {
    // The case the audience check exists for. Without it, a token issued for
    // any other internal tool would be accepted as a PIG session.
    const token = await mint({ sub: 'x', aud: 'some-other-app' });
    await assert.rejects(() => provider().verifyAccessToken(token));
  });

  it('rejects an expired token', async () => {
    const token = await mint({ sub: 'x', aud: 'pig' }, { expiresIn: '-1m' });
    await assert.rejects(() => provider().verifyAccessToken(token));
  });

  it('rejects a token with no subject', async () => {
    const token = await mint({ aud: 'pig' });
    await assert.rejects(() => provider().verifyAccessToken(token));
  });

  it('rejects garbage', async () => {
    await assert.rejects(() => provider().verifyAccessToken('not-a-token'));
  });

  it('surfaces a discovery failure and retries on the next call', async () => {
    const p = createOidcAuthProvider({ issuer: origin, audience: 'pig' });
    const token = await mint({ sub: 'user-6', aud: 'pig' });

    discoveryStatus = 503;
    await assert.rejects(() => p.verifyAccessToken(token));

    // The failure must not be cached for the process lifetime: an identity
    // provider that reboots should not permanently break PIG.
    discoveryStatus = 200;
    assert.equal((await p.verifyAccessToken(token)).subject, 'user-6');
  });
});

describe('createConfiguredAuthProvider', () => {
  it('prefers OIDC when both are configured', () => {
    const provider = createConfiguredAuthProvider({
      SUPABASE_URL: 'https://project.supabase.co',
      PIG_OIDC_ISSUER: 'https://id.customer.internal',
      PIG_OIDC_JWKS_URI: undefined,
      PIG_OIDC_AUDIENCE: 'pig',
      PIG_OIDC_EMAIL_CLAIMS: undefined,
    });
    // So an on-prem install can leave the hosted values in place without them
    // silently taking over.
    assert.equal(provider?.name, 'oidc');
  });

  it('uses Supabase when only it is configured', () => {
    const provider = createConfiguredAuthProvider({
      SUPABASE_URL: 'https://project.supabase.co',
      PIG_OIDC_ISSUER: undefined,
      PIG_OIDC_JWKS_URI: undefined,
      PIG_OIDC_AUDIENCE: undefined,
      PIG_OIDC_EMAIL_CLAIMS: undefined,
    });
    assert.equal(provider?.name, 'supabase');
  });

  it('returns null when neither is configured', () => {
    const provider = createConfiguredAuthProvider({
      SUPABASE_URL: undefined,
      PIG_OIDC_ISSUER: undefined,
      PIG_OIDC_JWKS_URI: undefined,
      PIG_OIDC_AUDIENCE: undefined,
      PIG_OIDC_EMAIL_CLAIMS: undefined,
    });
    // The production guard in config.ts turns this into a refusal to start.
    assert.equal(provider, null);
  });
});
