import {
  __resetJwksCacheForTests,
  decodePubSubJwt,
  decodePubSubMessage,
  verifyPubSubJwt,
} from '../../../src/integrations/google-play/verify';

const buildJwt = (claims: Record<string, unknown>, header: Record<string, unknown> = { alg: 'RS256', typ: 'JWT' }): string => {
  const headerSeg = Buffer.from(JSON.stringify(header)).toString('base64url');
  const payload = Buffer.from(JSON.stringify(claims)).toString('base64url');
  return `${headerSeg}.${payload}.signature-mock`;
};

const AUDIENCE = 'https://example.com/api/integrations/google-play/webhook';
const NOW = 1700000000;

beforeEach(() => {
  __resetJwksCacheForTests();
});

describe('decodePubSubJwt', () => {
  it('decodes claims from the payload segment', () => {
    const token = buildJwt({ iss: 'https://accounts.google.com', aud: AUDIENCE, iat: NOW, exp: NOW + 600 });
    const claims = decodePubSubJwt(token);
    expect(claims.iss).toBe('https://accounts.google.com');
    expect(claims.aud).toBe(AUDIENCE);
  });

  it('throws on malformed JWT', () => {
    expect(() => decodePubSubJwt('not.a.jwt.extra')).toThrow(/3 segments/);
    expect(() => decodePubSubJwt('only-one-segment')).toThrow(/3 segments/);
  });
});

// Claims-level tests use skipSignature so we can synthesize JWTs without RSA
// key material. These prove the claim validation logic is correct; the
// signature path is covered separately below.
describe('verifyPubSubJwt — claim validation (signature skipped)', () => {
  const validClaims = { iss: 'https://accounts.google.com', aud: AUDIENCE, iat: NOW, exp: NOW + 600 };
  const opts = { expectedAudience: AUDIENCE, now: NOW, skipSignature: true };

  it('passes for well-formed Google-issued JWT with matching audience', async () => {
    const token = buildJwt(validClaims);
    const claims = await verifyPubSubJwt(token, opts);
    expect(claims.aud).toBe(AUDIENCE);
  });

  it('also accepts `accounts.google.com` issuer (no https prefix)', async () => {
    const token = buildJwt({ ...validClaims, iss: 'accounts.google.com' });
    await expect(verifyPubSubJwt(token, opts)).resolves.toBeDefined();
  });

  it('rejects an unknown issuer', async () => {
    const token = buildJwt({ ...validClaims, iss: 'https://evil.example.com' });
    await expect(verifyPubSubJwt(token, opts)).rejects.toThrow(/issuer not recognized/);
  });

  it('rejects audience mismatch', async () => {
    const token = buildJwt({ ...validClaims, aud: 'https://other.example.com/webhook' });
    await expect(verifyPubSubJwt(token, opts)).rejects.toThrow(/audience mismatch/);
  });

  it('rejects expired tokens beyond clock tolerance', async () => {
    const token = buildJwt({ ...validClaims, exp: NOW - 200 });
    await expect(
      verifyPubSubJwt(token, { ...opts, clockTolerance: 60 })
    ).rejects.toThrow(/expired/);
  });

  it('rejects tokens issued in the future beyond clock tolerance', async () => {
    const token = buildJwt({ ...validClaims, iat: NOW + 200 });
    await expect(
      verifyPubSubJwt(token, { ...opts, clockTolerance: 60 })
    ).rejects.toThrow(/future/);
  });

  it('accepts tokens slightly outside exp/iat within clock tolerance', async () => {
    const tokenSlightlyExpired = buildJwt({ ...validClaims, exp: NOW - 30 });
    await expect(
      verifyPubSubJwt(tokenSlightlyExpired, { ...opts, clockTolerance: 60 })
    ).resolves.toBeDefined();
  });

  // Sender-identity binding (PR #1381 review P1): when expectedEmail is set, the
  // token must carry email_verified=true and a matching email.
  const SA = 'pubsub-push@my-project.iam.gserviceaccount.com';

  it('accepts when expectedEmail matches and email_verified=true', async () => {
    const token = buildJwt({ ...validClaims, email: SA, email_verified: true });
    await expect(verifyPubSubJwt(token, { ...opts, expectedEmail: SA })).resolves.toBeDefined();
  });

  it('rejects when the email does not match the configured service account', async () => {
    const token = buildJwt({ ...validClaims, email: 'attacker@evil.example.com', email_verified: true });
    await expect(verifyPubSubJwt(token, { ...opts, expectedEmail: SA })).rejects.toThrow(/email mismatch/);
  });

  it('rejects when email_verified is not true', async () => {
    const token = buildJwt({ ...validClaims, email: SA, email_verified: false });
    await expect(verifyPubSubJwt(token, { ...opts, expectedEmail: SA })).rejects.toThrow(/email not verified/);
  });

  it('rejects when the email claim is missing entirely', async () => {
    const token = buildJwt({ ...validClaims });
    await expect(verifyPubSubJwt(token, { ...opts, expectedEmail: SA })).rejects.toThrow(/email not verified/);
  });

  it('does not enforce email when expectedEmail is unset (back-compat)', async () => {
    const token = buildJwt({ ...validClaims });
    await expect(verifyPubSubJwt(token, opts)).resolves.toBeDefined();
  });
});

// Signature-level tests use a freshly generated RSA keypair so we can produce
// real RS256 JWTs and assert the verifier's signature check actually runs.
describe('verifyPubSubJwt — RS256 signature verification', () => {
  const validClaims = { iss: 'https://accounts.google.com', aud: AUDIENCE, iat: NOW, exp: NOW + 600 };
  const KID = 'test-kid-2026';

  let privateKey: CryptoKey;
  let publicKey: CryptoKey;
  let fetchJwks: () => Promise<Map<string, CryptoKey>>;

  beforeAll(async () => {
    const pair = await globalThis.crypto.subtle.generateKey(
      { name: 'RSASSA-PKCS1-v1_5', modulusLength: 2048, publicExponent: new Uint8Array([1, 0, 1]), hash: 'SHA-256' },
      true,
      ['sign', 'verify']
    );
    privateKey = pair.privateKey;
    publicKey = pair.publicKey;
    const keyMap = new Map<string, CryptoKey>([[KID, publicKey]]);
    fetchJwks = async () => keyMap;
  });

  const signJwt = async (claims: Record<string, unknown>, header: Record<string, unknown> = { alg: 'RS256', kid: KID, typ: 'JWT' }): Promise<string> => {
    const headerSeg = Buffer.from(JSON.stringify(header)).toString('base64url');
    const payloadSeg = Buffer.from(JSON.stringify(claims)).toString('base64url');
    const signingInput = new TextEncoder().encode(`${headerSeg}.${payloadSeg}`);
    const sig = await globalThis.crypto.subtle.sign({ name: 'RSASSA-PKCS1-v1_5' }, privateKey, signingInput);
    const sigSeg = Buffer.from(new Uint8Array(sig)).toString('base64url');
    return `${headerSeg}.${payloadSeg}.${sigSeg}`;
  };

  it('accepts a JWT signed with the matching JWKS key', async () => {
    const token = await signJwt(validClaims);
    const claims = await verifyPubSubJwt(token, { expectedAudience: AUDIENCE, now: NOW, fetchJwks });
    expect(claims.aud).toBe(AUDIENCE);
  });

  it('rejects a JWT whose signature has been tampered with', async () => {
    const token = await signJwt(validClaims);
    const tampered = token.slice(0, -4) + 'AAAA';
    await expect(
      verifyPubSubJwt(tampered, { expectedAudience: AUDIENCE, now: NOW, fetchJwks })
    ).rejects.toThrow(/signature verification failed/);
  });

  it('rejects a JWT signed by a different key (kid not in JWKS)', async () => {
    const token = await signJwt(validClaims, { alg: 'RS256', kid: 'unknown-kid', typ: 'JWT' });
    await expect(
      verifyPubSubJwt(token, { expectedAudience: AUDIENCE, now: NOW, fetchJwks })
    ).rejects.toThrow(/kid not found/);
  });

  it('rejects JWTs that declare a non-RS256 alg', async () => {
    const headerSeg = Buffer.from(JSON.stringify({ alg: 'HS256', kid: KID })).toString('base64url');
    const payloadSeg = Buffer.from(JSON.stringify(validClaims)).toString('base64url');
    const token = `${headerSeg}.${payloadSeg}.unused-sig`;
    await expect(
      verifyPubSubJwt(token, { expectedAudience: AUDIENCE, now: NOW, fetchJwks })
    ).rejects.toThrow(/alg not supported/);
  });

  it('rejects JWTs missing kid in header', async () => {
    const headerSeg = Buffer.from(JSON.stringify({ alg: 'RS256', typ: 'JWT' })).toString('base64url');
    const payloadSeg = Buffer.from(JSON.stringify(validClaims)).toString('base64url');
    const signingInput = new TextEncoder().encode(`${headerSeg}.${payloadSeg}`);
    const sig = await globalThis.crypto.subtle.sign({ name: 'RSASSA-PKCS1-v1_5' }, privateKey, signingInput);
    const sigSeg = Buffer.from(new Uint8Array(sig)).toString('base64url');
    const token = `${headerSeg}.${payloadSeg}.${sigSeg}`;
    await expect(
      verifyPubSubJwt(token, { expectedAudience: AUDIENCE, now: NOW, fetchJwks })
    ).rejects.toThrow(/missing `kid`/);
  });

  it('still applies claim checks even with valid signature (audience mismatch)', async () => {
    const token = await signJwt({ ...validClaims, aud: 'https://other/webhook' });
    await expect(
      verifyPubSubJwt(token, { expectedAudience: AUDIENCE, now: NOW, fetchJwks })
    ).rejects.toThrow(/audience mismatch/);
  });
});

describe('decodePubSubMessage', () => {
  it('decodes base64 data into JSON', () => {
    const inner = { version: '1.0', packageName: 'com.example' };
    const envelope = {
      message: {
        data: Buffer.from(JSON.stringify(inner)).toString('base64'),
        messageId: 'msg-1',
        publishTime: '2024-01-01T00:00:00Z',
      },
      subscription: 'projects/p/subscriptions/s',
    };
    const decoded = decodePubSubMessage<typeof inner>(envelope);
    expect(decoded).toEqual(inner);
  });

  it('throws when message.data is missing', () => {
    expect(() => decodePubSubMessage({ message: { data: '', messageId: '', publishTime: '' }, subscription: '' })).toThrow(
      /no message.data/
    );
  });
});
