import { ConfigService } from '@nestjs/config';
import { JwtService } from '@nestjs/jwt';
import { Test, TestingModule } from '@nestjs/testing';
import { generateKeyPairSync, randomUUID } from 'node:crypto';
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { JwtSignerAdapter } from './jwt-signer.adapter';

describe('JwtSignerAdapter', () => {
  let tempDir: string;
  let privateKeyPath: string;
  let publicKey: string;
  let adapter: JwtSignerAdapter;
  let verifier: JwtService;

  beforeAll(() => {
    const keypair = generateKeyPairSync('rsa', {
      modulusLength: 2048,
      privateKeyEncoding: { format: 'pem', type: 'pkcs1' },
      publicKeyEncoding: { format: 'pem', type: 'pkcs1' },
    });
    publicKey = keypair.publicKey;

    tempDir = mkdtempSync(join(tmpdir(), 'jwt-signer-spec-'));
    privateKeyPath = join(tempDir, 'oauth-private.key');
    writeFileSync(privateKeyPath, keypair.privateKey);

    verifier = new JwtService({ publicKey });
  });

  afterAll(() => {
    rmSync(tempDir, { recursive: true, force: true });
  });

  beforeEach(async () => {
    const config = {
      get: jest.fn((key: string, fallback?: unknown) => {
        if (key === 'OAUTH_STRATEGY') return 'local-provider';
        if (key === 'OAUTH_PRIVATE_KEY_PATH') return privateKeyPath;
        if (key === 'JWT_ACCESS_TTL_SECONDS') return 3600;
        if (key === 'JWT_REFRESH_TTL_SECONDS') return 2592000;
        return fallback;
      }),
    };

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

    adapter = module.get(JwtSignerAdapter);
  });

  describe('signAccess', () => {
    it('issues a JWT with iss="Aurora Catalyst" and exactly the claims {aci, iss, iat, exp}', async () => {
      const aci = randomUUID();

      const { token, expiresAt } = await adapter.signAccess({ aci });

      const decoded = verifier.verify<Record<string, unknown>>(token, { algorithms: ['RS256'] });

      expect(Object.keys(decoded).sort()).toEqual(['aci', 'exp', 'iat', 'iss']);
      expect(decoded.aci).toBe(aci);
      expect(decoded.iss).toBe('Aurora Catalyst');
      expect(typeof decoded.iat).toBe('number');
      expect(typeof decoded.exp).toBe('number');
      expect(expiresAt).toBeInstanceOf(Date);
    });

    it('signs with RS256 algorithm in the JWT header', async () => {
      const { token } = await adapter.signAccess({ aci: randomUUID() });

      const header = JSON.parse(
        Buffer.from(token.split('.')[0], 'base64url').toString('utf-8'),
      ) as Record<string, unknown>;

      expect(header.alg).toBe('RS256');
      expect(header.typ).toBe('JWT');
    });

    it('respects JWT_ACCESS_TTL_SECONDS: exp - iat equals configured value', async () => {
      const { token } = await adapter.signAccess({ aci: randomUUID() });

      const decoded = verifier.verify<Record<string, number>>(token, { algorithms: ['RS256'] });

      expect(decoded.exp - decoded.iat).toBe(3600);
    });
  });

  describe('signRefresh', () => {
    it('returns an opaque 64-character URL-safe string (not a JWT)', async () => {
      const { token, expiresAt } = await adapter.signRefresh();

      expect(token).toHaveLength(64);
      expect(token).toMatch(/^[A-Za-z0-9_-]+$/);
      expect(token.split('.')).toHaveLength(1); // not a JWT (no dots)
      expect(expiresAt).toBeInstanceOf(Date);
    });

    it('issues different refresh tokens on consecutive calls', async () => {
      const a = await adapter.signRefresh();
      const b = await adapter.signRefresh();
      expect(a.token).not.toBe(b.token);
    });

    it('respects JWT_REFRESH_TTL_SECONDS: expiresAt is now + configured seconds', async () => {
      const before = Date.now();
      const { expiresAt } = await adapter.signRefresh();
      const after = Date.now();

      // expiresAt should be between (before + 30d) and (after + 30d)
      const thirtyDaysMs = 2592000 * 1000;
      expect(expiresAt.getTime()).toBeGreaterThanOrEqual(before + thirtyDaysMs);
      expect(expiresAt.getTime()).toBeLessThanOrEqual(after + thirtyDaysMs);
    });
  });

  // ── IHubSessionManager methods ────────────────────────────────────────────────
  // JwtSignerAdapter implements both IJwtSigner and IHubSessionManager using the
  // same RS256 key pair. These tests verify the hub-session lifecycle methods.

  describe('signHubSession', () => {
    it('returns a signed JWT string for the given accountId', () => {
      const accountId = randomUUID();

      const token = adapter.signHubSession(accountId);

      expect(typeof token).toBe('string');
      expect(token.split('.')).toHaveLength(3); // RS256 JWT
    });

    it('hub_session JWT carries { sub: accountId, type: hub_session }', () => {
      const accountId = randomUUID();

      const token = adapter.signHubSession(accountId);

      const decoded = verifier.verify<Record<string, unknown>>(token, { algorithms: ['RS256'] });
      expect(decoded.sub).toBe(accountId);
      expect(decoded.type).toBe('hub_session');
    });
  });

  describe('verifyHubSession', () => {
    it('returns the accountId for a valid hub_session token', () => {
      const accountId = randomUUID();
      const token = adapter.signHubSession(accountId);

      const result = adapter.verifyHubSession(token);

      expect(result).toBe(accountId);
    });

    it('returns null for a tampered or invalid token', () => {
      const result = adapter.verifyHubSession('not.a.valid.token');

      expect(result).toBeNull();
    });

    it('returns null for a regular access token (wrong type claim)', async () => {
      const { token: accessToken } = await adapter.signAccess({ aci: randomUUID() });

      // Access token has { aci } not { type: 'hub_session' } — must not pass hub session verify.
      const result = adapter.verifyHubSession(accessToken);

      expect(result).toBeNull();
    });
  });

  describe('verifyAccessToken', () => {
    it('returns the accountId (aci claim) for a valid access token', async () => {
      const aci = randomUUID();
      const { token } = await adapter.signAccess({ aci });

      const result = adapter.verifyAccessToken(token);

      expect(result).toBe(aci);
    });

    it('returns null for an invalid access token', () => {
      const result = adapter.verifyAccessToken('invalid.token.here');

      expect(result).toBeNull();
    });

    it('returns null for a hub_session token (wrong claim shape)', () => {
      const token = adapter.signHubSession(randomUUID());

      // hub_session JWT has { sub, type } not { aci } — must not return an aci claim.
      const result = adapter.verifyAccessToken(token);

      expect(result).toBeNull();
    });
  });
});

// ── aurora-hub mode ────────────────────────────────────────────────────────────

describe('JwtSignerAdapter — aurora-hub mode', () => {
  it('constructs without reading any key file (no readFileSync error)', async () => {
    // aurora-hub mode: the local RSA key pair is irrelevant. OAUTH_PRIVATE_KEY_PATH
    // is not required because the hub owns the key material. Construction MUST succeed
    // without any key file present on disk.
    const configService = {
      get: jest.fn((key: string, fallback?: unknown) => {
        if (key === 'OAUTH_STRATEGY') return 'aurora-hub';
        // Point the key path at a guaranteed-missing file so the test stays discriminating
        // even in a dev env where ./.keys/ exists: reverting the dormant guard would make the
        // constructor readFileSync this path → ENOENT, turning the test red.
        if (key === 'OAUTH_PRIVATE_KEY_PATH') return '/nonexistent/aurora-hub-test.key';
        return fallback;
      }),
    };

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

    const adapter = module.get(JwtSignerAdapter);
    expect(adapter).toBeDefined();
  });

  it('signAccess throws naming OAUTH_STRATEGY=aurora-hub in the error message', async () => {
    const configService = {
      get: jest.fn((key: string, fallback?: unknown) => {
        if (key === 'OAUTH_STRATEGY') return 'aurora-hub';
        // Point the key path at a guaranteed-missing file so the test stays discriminating
        // even in a dev env where ./.keys/ exists: reverting the dormant guard would make the
        // constructor readFileSync this path → ENOENT, turning the test red.
        if (key === 'OAUTH_PRIVATE_KEY_PATH') return '/nonexistent/aurora-hub-test.key';
        return fallback;
      }),
    };

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

    const adapter = module.get(JwtSignerAdapter);

    await expect(adapter.signAccess({ aci: 'test-id' })).rejects.toThrow(
      /token issuance disabled.*OAUTH_STRATEGY=aurora-hub/i,
    );
  });
});
