import { ConfigService } from '@nestjs/config';
import { Test, TestingModule } from '@nestjs/testing';
import { createHash, generateKeyPairSync } from 'node:crypto';
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { OAuthGetJwksHandler } from './o-auth-get-jwks.handler';

/**
 * Generates a fresh RSA keypair and writes the public PEM to a temp file so the handler
 * can read it via its `OAUTH_PUBLIC_KEY_PATH` config. Returns the path + PEM for assertion.
 */
const writeTmpPublicKeyPem = (
  modulusLength = 2048,
): { path: string; pem: string; tmpDir: string } => {
  const tmpDir = mkdtempSync(join(tmpdir(), 'jwks-spec-'));
  const path = join(tmpDir, 'oauth-public.key');
  const { publicKey } = generateKeyPairSync('rsa', { modulusLength });
  const pem = publicKey.export({ type: 'spki', format: 'pem' }) as string;
  writeFileSync(path, pem);
  return { path, pem, tmpDir };
};

describe('OAuthGetJwksHandler', () => {
  let handler: OAuthGetJwksHandler;
  let keyPath: string;
  let keyPem: string;
  let tmpDir: string;

  const buildModule = async (overridePath?: string): Promise<void> => {
    const configService = {
      get: jest.fn((key: string, fallback?: string) => {
        if (key === 'OAUTH_STRATEGY') return 'local-provider';
        if (key === 'OAUTH_PUBLIC_KEY_PATH') return overridePath ?? keyPath;
        return fallback;
      }),
    };

    const module: TestingModule = await Test.createTestingModule({
      providers: [OAuthGetJwksHandler, { provide: ConfigService, useValue: configService }],
    }).compile();

    handler = module.get(OAuthGetJwksHandler);
  };

  beforeEach(() => {
    const fixture = writeTmpPublicKeyPem();
    keyPath = fixture.path;
    keyPem = fixture.pem;
    tmpDir = fixture.tmpDir;
  });

  afterEach(() => {
    rmSync(tmpDir, { recursive: true, force: true });
  });

  describe('JWKS document shape (RFC 7517)', () => {
    it('returns a document with exactly one key', async () => {
      await buildModule();
      const jwks = handler.main();
      expect(jwks.keys).toHaveLength(1);
    });

    it('sets the canonical RSA signing fields on the key', async () => {
      await buildModule();
      const [key] = handler.main().keys;
      expect(key.kty).toBe('RSA');
      expect(key.alg).toBe('RS256');
      expect(key.use).toBe('sig');
    });

    it('exports the public key as JWK components `n` and `e`', async () => {
      await buildModule();
      const [key] = handler.main().keys;
      // RSA public key in JWK form MUST carry the modulus `n` and exponent `e`.
      expect(typeof key.n).toBe('string');
      expect(key.n.length).toBeGreaterThan(0);
      expect(typeof key.e).toBe('string');
      expect(key.e.length).toBeGreaterThan(0);
    });

    it('does NOT leak the private key material', async () => {
      await buildModule();
      const [key] = handler.main().keys;
      const serialized = JSON.stringify(key);
      // RSA private parameters: d, p, q, dp, dq, qi. None should appear in a JWKS doc.
      expect(serialized).not.toMatch(/"d":/);
      expect(serialized).not.toMatch(/"p":/);
      expect(serialized).not.toMatch(/"q":/);
    });
  });

  describe('kid (key identifier)', () => {
    it('uses the first 16 hex chars of sha256(PEM) as kid', async () => {
      await buildModule();
      const [key] = handler.main().keys;
      const expected = createHash('sha256').update(keyPem).digest('hex').substring(0, 16);
      expect(key.kid).toBe(expected);
    });

    it('produces the same kid across consecutive calls (deterministic)', async () => {
      await buildModule();
      const first = handler.main().keys[0].kid;
      const second = handler.main().keys[0].kid;
      expect(first).toBe(second);
    });

    it('produces a DIFFERENT kid when the underlying key rotates', async () => {
      await buildModule();
      const before = handler.main().keys[0].kid;

      // Rotate: build a new module instance pointing at a freshly generated key file.
      const rotated = writeTmpPublicKeyPem();
      await buildModule(rotated.path);
      const after = handler.main().keys[0].kid;

      expect(after).not.toBe(before);
      rmSync(rotated.tmpDir, { recursive: true, force: true });
    });
  });

  describe('memoization', () => {
    it('builds the JWKS once at construction and serves the same reference', async () => {
      await buildModule();
      const first = handler.main();
      const second = handler.main();
      // Identity comparison: the handler caches the document. Reading the PEM per
      // request would be wasteful since the key is static for a process lifetime.
      expect(first).toBe(second);
    });
  });

  describe('config resolution', () => {
    it('reads the path from OAUTH_PUBLIC_KEY_PATH when set', async () => {
      // Override env to a freshly generated key file in a different location.
      const alt = writeTmpPublicKeyPem();
      await buildModule(alt.path);

      const expected = createHash('sha256').update(alt.pem).digest('hex').substring(0, 16);
      expect(handler.main().keys[0].kid).toBe(expected);
      rmSync(alt.tmpDir, { recursive: true, force: true });
    });
  });

  describe('aurora-hub mode (satellite — no local signing key)', () => {
    const buildAuroraHubModule = async (): Promise<void> => {
      // Provides OAUTH_STRATEGY=aurora-hub but NO key path — the handler must NOT call
      // readFileSync. If the guard is missing, this test fails with ENOENT.
      const configService = {
        get: jest.fn((key: string, fallback?: string) => {
          if (key === 'OAUTH_STRATEGY') return 'aurora-hub';
          // Point the key path at a guaranteed-missing file so the no-throw test stays
          // discriminating even in a dev env where ./.keys/ exists: reverting the dormant
          // guard would make the constructor readFileSync this path → ENOENT, turning it red.
          if (key === 'OAUTH_PUBLIC_KEY_PATH') return '/nonexistent/aurora-hub-test.key';
          return fallback;
        }),
      };

      const module: TestingModule = await Test.createTestingModule({
        providers: [OAuthGetJwksHandler, { provide: ConfigService, useValue: configService }],
      }).compile();

      handler = module.get(OAuthGetJwksHandler);
    };

    it('constructs without reading any key file (no readFileSync error)', async () => {
      // A satellite has no local signing key. Construction MUST succeed without
      // OAUTH_PUBLIC_KEY_PATH being set or any key file existing on disk.
      await expect(buildAuroraHubModule()).resolves.not.toThrow();
      expect(handler).toBeDefined();
    });

    it('main() returns an empty key set — a satellite has no signing key to advertise', async () => {
      await buildAuroraHubModule();
      const jwks = handler.main();
      expect(jwks.keys).toHaveLength(0);
    });
  });
});
