// Google Cloud Pub/Sub Push 验签。
//
// Pub/Sub Push 推送的每个请求都会带 `Authorization: Bearer <JWT>` 头，JWT 由 Google
// 用其内部服务账号私钥签发（RS256），通过 https://www.googleapis.com/oauth2/v3/certs
// 拉到的 JWK Set 可以做真实签名校验。
//
// 此前 A2 mock 阶段仅做 claim 检查不验签，等同于 iOS JWS 漏洞的 Android 镜像版
// (CWE-347)。本文件接入 Web Crypto 完成 RS256 验签，运行时同时兼容 Node 22+ 与
// Cloudflare Workers（payment-kit 是统一代码）。

import logger from '../../libs/logger';

export type PubSubJwtClaims = {
  iss: string;
  aud: string;
  iat: number;
  exp: number;
  email?: string;
  email_verified?: boolean;
};

type JwtHeader = {
  alg: string;
  kid?: string;
  typ?: string;
};

const GOOGLE_PUBSUB_ISSUERS = new Set(['https://accounts.google.com', 'accounts.google.com']);
const GOOGLE_JWKS_URL = 'https://www.googleapis.com/oauth2/v3/certs';

// Cache parsed JWKS keys by `kid`. Google rotates keys ~daily; 1h TTL keeps us
// fresh enough without hammering the JWKS endpoint on every webhook.
const JWKS_CACHE_TTL_MS = 60 * 60 * 1000;
let jwksCache: { fetchedAt: number; keys: Map<string, CryptoKey> } | null = null;

function decodeSegment(segment: string): Buffer {
  return Buffer.from(segment, 'base64url');
}

function parseJsonSegment<T>(segment: string): T {
  return JSON.parse(decodeSegment(segment).toString('utf8')) as T;
}

/** Decode JWT claims (payload segment only). Does NOT verify signature. */
export function decodePubSubJwt(token: string): PubSubJwtClaims {
  const parts = token.split('.');
  if (parts.length !== 3) {
    throw new Error('Pub/Sub JWT format invalid: expected 3 segments');
  }
  return parseJsonSegment<PubSubJwtClaims>(parts[1]!);
}

export type FetchJwks = () => Promise<Map<string, CryptoKey>>;

export type VerifyOptions = {
  /** 当前 Worker 的公网 URL，必须匹配 JWT 的 `aud` claim */
  expectedAudience: string;
  /**
   * The Pub/Sub push subscription's OIDC service-account email. When set, the
   * JWT MUST carry `email_verified=true` and a matching `email` — otherwise any
   * Google-issued token for this audience could forge RTDN payloads (PR #1381
   * review P1). Leave undefined to skip (the call site logs a warning).
   */
  expectedEmail?: string;
  /** 容忍的时钟偏移（秒），默认 60 */
  clockTolerance?: number;
  /** 当前 unix 时间（秒），默认 Date.now() / 1000；用于测试注入 */
  now?: number;
  /** 覆盖 JWKS fetcher（测试用）。默认拉 Google `oauth2/v3/certs` 并缓存 1h */
  fetchJwks?: FetchJwks;
  /**
   * Bypass RS256 signature verification.
   *
   * Only safe values:
   *   - true in unit tests that use synthetic JWTs (no real signing key)
   *   - true via `GOOGLE_PUBSUB_SKIP_SIGNATURE_VERIFY=true` for local sandbox
   *     debugging — NEVER in production.
   */
  skipSignature?: boolean;
};

function defaultSkipSignature(): boolean {
  return process.env.GOOGLE_PUBSUB_SKIP_SIGNATURE_VERIFY === 'true';
}

/**
 * Production fail-closed wrapper around the skipSignature flag — whether it
 * came from `options.skipSignature` or `GOOGLE_PUBSUB_SKIP_SIGNATURE_VERIFY`.
 * In production we refuse to honor the bypass (logs loudly so the
 * misconfiguration is visible). The flag is meant for tests / local sandbox
 * debugging only; in production it would silently let any caller forge an
 * RTDN with arbitrary JWT claims (CWE-347).
 */
function effectiveSkipSignature(requested: boolean): boolean {
  if (!requested) return false;
  if (process.env.BLOCKLET_MODE === 'production') {
    logger.error(
      'google_play: signature verification skip refused in production — Pub/Sub JWT signature verification stays enabled'
    );
    return false;
  }
  return true;
}

async function fetchGoogleJwks(): Promise<Map<string, CryptoKey>> {
  if (jwksCache && Date.now() - jwksCache.fetchedAt < JWKS_CACHE_TTL_MS) {
    return jwksCache.keys;
  }

  const res = await fetch(GOOGLE_JWKS_URL);
  if (!res.ok) {
    throw new Error(`failed to fetch Google JWKS: HTTP ${res.status}`);
  }
  // Google JWKS entries always carry `kid` and `alg`; the lib `JsonWebKey` type
  // doesn't, so widen here. Other fields (`kty`, `n`, `e`, `use`) are validated
  // implicitly by `crypto.subtle.importKey`.
  const body = (await res.json()) as { keys: Array<JsonWebKey & { kid?: string; alg?: string }> };
  if (!Array.isArray(body?.keys)) {
    throw new Error('Google JWKS response missing `keys` array');
  }

  const keys = new Map<string, CryptoKey>();
  for (const jwk of body.keys) {
    // eslint-disable-next-line no-continue -- guard-style skip for JWK entries we won't use
    if (!jwk.kid) continue;
    // eslint-disable-next-line no-continue -- guard-style skip for JWK entries we won't use
    if (jwk.alg && jwk.alg !== 'RS256') continue;
    // eslint-disable-next-line no-await-in-loop -- sequential key import keeps the call deterministic + small N
    const key = await globalThis.crypto.subtle.importKey(
      'jwk',
      jwk,
      { name: 'RSASSA-PKCS1-v1_5', hash: 'SHA-256' },
      false,
      ['verify']
    );
    keys.set(jwk.kid, key);
  }
  jwksCache = { fetchedAt: Date.now(), keys };
  return keys;
}

/**
 * Full Pub/Sub JWT verification: RS256 signature + issuer + audience + expiry.
 *
 * Async because both the JWKS fetch and `crypto.subtle.verify` are async on
 * Web Crypto. Use `skipSignature: true` (or set
 * `GOOGLE_PUBSUB_SKIP_SIGNATURE_VERIFY=true`) ONLY for unit tests / local
 * sandbox debugging — never in production.
 */
export async function verifyPubSubJwt(token: string, options: VerifyOptions): Promise<PubSubJwtClaims> {
  const parts = token.split('.');
  if (parts.length !== 3) {
    throw new Error('Pub/Sub JWT format invalid: expected 3 segments');
  }
  const [headerSeg, payloadSeg, signatureSeg] = parts as [string, string, string];

  const header = parseJsonSegment<JwtHeader>(headerSeg);
  const claims = parseJsonSegment<PubSubJwtClaims>(payloadSeg);

  if (!GOOGLE_PUBSUB_ISSUERS.has(claims.iss)) {
    throw new Error(`Pub/Sub JWT issuer not recognized: ${claims.iss}`);
  }

  if (claims.aud !== options.expectedAudience) {
    throw new Error(`Pub/Sub JWT audience mismatch: got ${claims.aud}, expected ${options.expectedAudience}`);
  }

  const now = options.now ?? Math.floor(Date.now() / 1000);
  const tolerance = options.clockTolerance ?? 60;
  if (claims.exp + tolerance < now) {
    throw new Error(`Pub/Sub JWT expired (exp=${claims.exp}, now=${now})`);
  }
  if (claims.iat - tolerance > now) {
    throw new Error(`Pub/Sub JWT issued in the future (iat=${claims.iat}, now=${now})`);
  }

  // Sender-identity check: a valid Google-issued token for our audience is not
  // enough — require the token to belong to the configured Pub/Sub push service
  // account, with a verified email. Without this, any Google identity able to
  // mint a token for this audience could forge RTDN payloads (PR #1381 P1).
  if (options.expectedEmail) {
    if (claims.email_verified !== true) {
      throw new Error('Pub/Sub JWT email not verified');
    }
    if (claims.email !== options.expectedEmail) {
      throw new Error(`Pub/Sub JWT email mismatch: got ${claims.email ?? '(none)'}, expected ${options.expectedEmail}`);
    }
  }

  const skipSignature = effectiveSkipSignature(options.skipSignature ?? defaultSkipSignature());
  if (skipSignature) {
    logger.warn('Pub/Sub JWT signature verification skipped — set only in tests / local sandbox', {
      iss: claims.iss,
      aud: claims.aud,
    });
    return claims;
  }

  if (header.alg !== 'RS256') {
    throw new Error(`Pub/Sub JWT alg not supported: ${header.alg} (expected RS256)`);
  }
  if (!header.kid) {
    throw new Error('Pub/Sub JWT header missing `kid`');
  }

  const fetchKeys = options.fetchJwks ?? fetchGoogleJwks;
  const keys = await fetchKeys();
  const key = keys.get(header.kid);
  if (!key) {
    throw new Error(`Pub/Sub JWT kid not found in JWKS: ${header.kid}`);
  }

  const signingInput = new TextEncoder().encode(`${headerSeg}.${payloadSeg}`);
  const signature = decodeSegment(signatureSeg);
  const ok = await globalThis.crypto.subtle.verify(
    { name: 'RSASSA-PKCS1-v1_5', hash: 'SHA-256' },
    key,
    signature,
    signingInput
  );
  if (!ok) {
    throw new Error('Pub/Sub JWT signature verification failed');
  }

  return claims;
}

/** Test-only: reset the in-memory JWKS cache between cases. */
// eslint-disable-next-line @typescript-eslint/naming-convention
export function __resetJwksCacheForTests(): void {
  jwksCache = null;
}

export type PubSubMessage = {
  message: {
    data: string; // base64-encoded JSON
    messageId: string;
    publishTime: string;
    attributes?: Record<string, string>;
  };
  subscription: string;
};

/** Decode the base64 `data` field inside a Pub/Sub push envelope. */
export function decodePubSubMessage<T = unknown>(envelope: PubSubMessage): T {
  if (!envelope?.message?.data) {
    throw new Error('Pub/Sub envelope has no message.data');
  }
  const decoded = Buffer.from(envelope.message.data, 'base64').toString('utf8');
  return JSON.parse(decoded) as T;
}
