import { describe, test, expect } from 'bun:test';
import { generateKeyPairSync, createVerify } from 'node:crypto';
import { ApnsAuthToken } from './apns-jwt';
import { buildApnsPayload, collapseId } from './apns-payload';
import { renderTitle } from './text';
import type { Notification } from './types';

// What can be proven without Apple: the token is a well-formed ES256 JWT with the encoding Apple
// actually requires, and the payload never carries content.

const { privateKey, publicKey } = generateKeyPairSync('ec', { namedCurve: 'P-256' });
const keyP8 = privateKey.export({ format: 'pem', type: 'pkcs8' }) as string;
const auth = () => new ApnsAuthToken({ keyP8, keyId: 'ABCDE12345', teamId: 'TEAM123456' });

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

describe('APNs provider token', () => {
  test('is a three-part JWT with the header Apple expects', () => {
    const [h, c, s] = auth().get().split('.');
    expect(decode(h!)).toEqual({ alg: 'ES256', kid: 'ABCDE12345' });
    expect(decode(c!).iss).toBe('TEAM123456');
    expect(typeof decode(c!).iat).toBe('number');
    expect(s!.length).toBeGreaterThan(0);
  });

  // The whole point of dsaEncoding: 'ieee-p1363'. Node's default DER encoding is a *valid* ECDSA
  // signature that Apple rejects, and the failure gives no hint as to why — so assert the length.
  test('signs with raw r||s (64 bytes), not DER', () => {
    const sig = Buffer.from(auth().get().split('.')[2]!, 'base64url');
    expect(sig.length).toBe(64);
  });

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

  // Apple rejects tokens minted more than once per 20 minutes (TooManyProviderTokenUpdates), so a fresh
  // JWT per push would eventually lock us out.
  test('is cached rather than minted per call', () => {
    const a = auth();
    expect(a.get()).toBe(a.get());
  });

  test('accepts a key whose newlines were escaped by the environment', () => {
    const escaped = keyP8.replace(/\n/g, '\\n');
    expect(() => new ApnsAuthToken({ keyP8: escaped, keyId: 'K', teamId: 'T' }).get()).not.toThrow();
  });
});

describe('APNs payload', () => {
  const mail: Notification = { type: 'mail', userId: 1, count: 3 };

  test('carries a generic title and no content', () => {
    const p = buildApnsPayload(mail);
    expect(p.aps.alert.title).toBe('3 new emails');
    expect(p.aps.badge).toBe(3);
    expect(p.officer).toEqual({ type: 'mail' });
  });

  // The doorbell rule, asserted rather than trusted: whatever a producer sends, nothing resembling
  // content may appear in the serialized payload that leaves for Apple.
  test('a producer cannot smuggle content through', () => {
    const sneaky = {
      type: 'mail',
      userId: 1,
      count: 1,
      // none of these are part of Notification, but a caller in JS could still pass them
      subject: 'Re: your invoice',
      from: 'accounts@example.com',
      body: 'please pay',
    } as unknown as Notification;

    const json = JSON.stringify(buildApnsPayload(sneaky));
    expect(json).not.toContain('invoice');
    expect(json).not.toContain('example.com');
    expect(json).not.toContain('please pay');
  });

  test('ids travel, so the app can fetch the real thing', () => {
    const p = buildApnsPayload({ type: 'job', userId: 1, id: 412, ok: false });
    expect(p.officer).toEqual({ type: 'job', id: 412, ok: false });
    expect(p.aps.alert.title).toBe('Job failed');
    expect(p.aps.badge).toBeUndefined();
  });

  test('threads by category so banners group instead of stacking', () => {
    expect(buildApnsPayload(mail).aps['thread-id']).toBe('mail');
  });

  // Collapse replaces an undelivered notification. Right for mail (one count supersedes the last),
  // wrong for jobs, where each completion is its own event.
  test('collapses mail by category and jobs by id', () => {
    expect(collapseId(mail)).toBe('mail');
    expect(collapseId({ type: 'job', userId: 1, id: 412 })).toBe('job-412');
  });
});

describe('rendered text', () => {
  test('singular and plural mail read correctly', () => {
    expect(renderTitle({ type: 'mail', userId: 1, count: 1 })).toBe('New email');
    expect(renderTitle({ type: 'mail', userId: 1, count: 5 })).toBe('5 new emails');
  });

  test('job wording follows ok', () => {
    expect(renderTitle({ type: 'job', userId: 1, ok: true })).toBe('Job finished');
    expect(renderTitle({ type: 'job', userId: 1, ok: false })).toBe('Job failed');
  });
});
