import { createSign, createPrivateKey, type KeyObject } from 'node:crypto';

// FCM v1 authorization: unlike APNs, the signed JWT is not the credential Google accepts. It is an
// *assertion* you exchange at the OAuth2 endpoint for a short-lived access token, and that token is what
// goes in the Authorization header. So there are two things to cache, not one, and a network round trip
// on the cold path that APNs does not have.
//
// The private key comes from a service-account JSON downloaded from the Firebase console. It arrives
// with literal \n sequences inside the PEM, which have to be unescaped or createPrivateKey throws.

const TOKEN_URL = 'https://oauth2.googleapis.com/token';
const SCOPE = 'https://www.googleapis.com/auth/firebase.messaging';
/** Google issues 1-hour tokens; refresh early so a request never races the expiry. */
const REFRESH_MARGIN_MS = 5 * 60 * 1000;

export type ServiceAccount = {
  project_id: string;
  client_email: string;
  private_key: string;
};

export function parseServiceAccount(raw: string): ServiceAccount {
  const parsed = JSON.parse(raw) as Partial<ServiceAccount>;
  if (!parsed.project_id || !parsed.client_email || !parsed.private_key) {
    throw new Error('FCM service account is missing project_id, client_email or private_key');
  }
  return parsed as ServiceAccount;
}

const b64url = (input: object | Buffer): string =>
  (Buffer.isBuffer(input) ? input : Buffer.from(JSON.stringify(input))).toString('base64url');

export class FcmAccessToken {
  private key: KeyObject;
  private cached: { token: string; expiresAt: number } | null = null;
  /** In-flight refresh, so a burst of pushes makes one token request rather than one each. */
  private inflight: Promise<string> | null = null;

  constructor(private account: ServiceAccount) {
    this.key = createPrivateKey({ key: account.private_key.replace(/\\n/g, '\n'), format: 'pem' });
  }

  get projectId(): string {
    return this.account.project_id;
  }

  /** The signed assertion Google exchanges for an access token. Exposed for testing. */
  buildAssertion(now = Date.now()): string {
    const iat = Math.floor(now / 1000);
    const header = b64url({ alg: 'RS256', typ: 'JWT' });
    const claims = b64url({
      iss: this.account.client_email,
      scope: SCOPE,
      aud: TOKEN_URL,
      iat,
      exp: iat + 3600,
    });
    const signingInput = `${header}.${claims}`;
    const signer = createSign('RSA-SHA256');
    signer.update(signingInput);
    return `${signingInput}.${b64url(signer.sign(this.key))}`;
  }

  async get(): Promise<string> {
    const now = Date.now();
    if (this.cached && this.cached.expiresAt - now > REFRESH_MARGIN_MS) return this.cached.token;
    if (this.inflight) return this.inflight;

    this.inflight = (async () => {
      try {
        const res = await fetch(TOKEN_URL, {
          method: 'POST',
          headers: { 'content-type': 'application/x-www-form-urlencoded' },
          body: new URLSearchParams({
            grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer',
            assertion: this.buildAssertion(now),
          }),
          signal: AbortSignal.timeout(10_000),
        });
        if (!res.ok) {
          // Never log the body: it echoes the assertion, which is signed with the service-account key.
          throw new Error(`FCM token exchange failed: ${res.status}`);
        }
        const body = (await res.json()) as { access_token?: string; expires_in?: number };
        if (!body.access_token) throw new Error('FCM token exchange returned no access_token');

        this.cached = {
          token: body.access_token,
          expiresAt: now + (body.expires_in ?? 3600) * 1000,
        };
        return this.cached.token;
      } finally {
        this.inflight = null;
      }
    })();

    return this.inflight;
  }
}
