import { describe, test, expect } from 'bun:test';
import { generateKeyPairSync, createVerify } from 'node:crypto';
import { FcmAccessToken, parseServiceAccount } from './fcm-auth';
import { __testing } from './fcm';
import type { Notification } from './types';

// What can be proven without Google: the assertion is a well-formed RS256 JWT with the claims the token
// endpoint requires, and the message body matches FCM v1's shape — including the string-only `data` rule
// that is the usual reason a first integration fails.

const { privateKey, publicKey } = generateKeyPairSync('rsa', { modulusLength: 2048 });
const account = {
  project_id: 'officer-test',
  client_email: 'push@officer-test.iam.gserviceaccount.com',
  private_key: privateKey.export({ format: 'pem', type: 'pkcs8' }) as string,
};

const decode = (part: string) => JSON.parse(Buffer.from(part, 'base64url').toString());

describe('FCM service account', () => {
  test('rejects an incomplete account rather than failing at send time', () => {
    expect(() => parseServiceAccount('{"project_id":"x"}')).toThrow(/missing/);
  });

  test('accepts a key whose newlines were escaped, as they are in the JSON', () => {
    const escaped = { ...account, private_key: account.private_key.replace(/\n/g, '\\n') };
    expect(() => new FcmAccessToken(escaped).buildAssertion()).not.toThrow();
  });
});

describe('OAuth2 assertion', () => {
  const auth = new FcmAccessToken(account);

  test('carries the claims Google requires', () => {
    const [h, c] = auth.buildAssertion().split('.');
    expect(decode(h!)).toEqual({ alg: 'RS256', typ: 'JWT' });
    const claims = decode(c!);
    expect(claims.iss).toBe(account.client_email);
    expect(claims.aud).toBe('https://oauth2.googleapis.com/token');
    expect(claims.scope).toBe('https://www.googleapis.com/auth/firebase.messaging');
    expect(claims.exp - claims.iat).toBe(3600);
  });

  test('the signature verifies over header.claims', () => {
    const [h, c, s] = auth.buildAssertion().split('.');
    const v = createVerify('RSA-SHA256');
    v.update(`${h}.${c}`);
    expect(v.verify(publicKey, Buffer.from(s!, 'base64url'))).toBe(true);
  });
});

describe('FCM message', () => {
  const build = __testing.buildMessage;

  // The rule that breaks first integrations: FCM rejects a non-string in `data` with a bare
  // INVALID_ARGUMENT that says nothing about which field.
  test('every data value is a string, including numbers and booleans', () => {
    const msg = build('tok', { type: 'job', userId: 1, id: 412, ok: false, count: 2 });
    for (const [key, value] of Object.entries(msg.message.data)) {
      expect(typeof value, `data.${key}`).toBe('string');
    }
    expect(msg.message.data).toEqual({ type: 'job', id: '412', ok: 'false', count: '2' });
  });

  test('omits keys that were not provided, rather than sending "undefined"', () => {
    const msg = build('tok', { type: 'mail', userId: 1, count: 3 });
    expect(msg.message.data).toEqual({ type: 'mail', count: '3' });
    expect(Object.keys(msg.message.data)).not.toContain('id');
  });

  test('title is the generic rendered one and the token is set', () => {
    const msg = build('device-token', { type: 'mail', userId: 1, count: 3 });
    expect(msg.message.token).toBe('device-token');
    expect(msg.message.notification.title).toBe('3 new emails');
  });

  test('tags by category so banners group instead of stacking', () => {
    expect(build('t', { type: 'mail', userId: 1 }).message.android.notification.tag).toBe('mail');
  });

  // The same doorbell guarantee asserted for APNs, on the other channel.
  test('a producer cannot smuggle content through', () => {
    const sneaky = {
      type: 'mail',
      userId: 1,
      count: 1,
      subject: 'Re: your invoice',
      from: 'accounts@example.com',
    } as unknown as Notification;

    const json = JSON.stringify(build('tok', sneaky));
    expect(json).not.toContain('invoice');
    expect(json).not.toContain('example.com');
  });
});
