// Google Play Developer API client.
//
// Original implementation used `google-play-billing-validator` (a Node-only
// wrapper around `request`). That doesn't run on CF Workers — the `request`
// HTTP library predates streams as we know them and trips even on nodejs_compat.
// This rewrite uses fetch + Web Crypto, which work identically in Node 18+
// and in CF Workers, so /verify works in both targets.
//
// Read methods (verifySub / verifyINAPP) → GET androidpublisher.googleapis.com
// Write methods (acknowledge) → POST androidpublisher.googleapis.com
//
// Intentionally absent: cancel / refund. Google Play subscription cancellation
// and refund are the user's prerogative via Play Store «管理订阅» / Play Console
// (for developer-initiated refunds). Exposing server-side cancel/refund from
// Payment Kit would diverge our local mirror from Google's source of truth.
// State changes flow IN via RTDN webhooks → handlers/, never OUT from us.

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

export type GooglePlaySubscriptionPurchase = {
  /** Subscription Status of the order: 0 = pending, 1 = active, 2 = canceled */
  paymentState?: 0 | 1 | 2;
  /** RFC3339 UTC timestamp of expiry */
  expiryTimeMillis?: string;
  /** RFC3339 UTC timestamp of auto-resume */
  autoResumeTimeMillis?: string;
  /** Subscription auto-renewing flag */
  autoRenewing?: boolean;
  /** Price paid by user in micro-units of price_currency_code */
  priceAmountMicros?: string;
  priceCurrencyCode?: string;
  /** 0 = active, 1 = canceled by user, 2 = canceled by system, 3 = replaced by another subscription, 4 = canceled by developer */
  cancelReason?: 0 | 1 | 2 | 3 | 4;
  /** Acknowledge state: 0 = yet to be, 1 = acknowledged */
  acknowledgementState?: 0 | 1;
  /** UUID-like identifier of the user account, passed when calling launchBillingFlow as obfuscatedAccountId */
  obfuscatedExternalAccountId?: string;
  obfuscatedExternalProfileId?: string;
  orderId?: string;
  startTimeMillis?: string;
  countryCode?: string;
  /** PURCHASE / SUBSCRIPTION */
  kind?: string;
};

export type GooglePlayProductPurchase = {
  kind: string;
  purchaseTimeMillis?: string;
  purchaseState?: 0 | 1 | 2; // 0 = purchased, 1 = canceled, 2 = pending
  consumptionState?: 0 | 1;
  developerPayload?: string;
  orderId?: string;
  purchaseType?: 0 | 1 | 2;
  acknowledgementState?: 0 | 1;
  obfuscatedExternalAccountId?: string;
  obfuscatedExternalProfileId?: string;
  regionCode?: string;
};

export type GooglePlaySettings = {
  package_name: string;
  /** Stringified service account credentials JSON downloaded from GCP Console. */
  service_account_json: string;
};

const ANDROID_PUBLISHER_BASE = 'https://androidpublisher.googleapis.com/androidpublisher/v3';
const OAUTH_TOKEN_URL = 'https://oauth2.googleapis.com/token';
const ANDROID_PUBLISHER_SCOPE = 'https://www.googleapis.com/auth/androidpublisher';
const ACCESS_TOKEN_REFRESH_LEEWAY_SEC = 60;

type AccessTokenCache = { token: string; expiresAt: number };

export class GooglePlayClient {
  declare readonly packageName: string;

  private readonly clientEmail: string;

  private readonly privateKeyPem: string;

  /** In-memory access-token cache; sized 1 because each client maps to one SA. */
  private accessToken: AccessTokenCache | null = null;

  /** Lazily-imported CryptoKey — Web Crypto can only import once per key. */
  private signingKey: Promise<CryptoKey> | null = null;

  private constructor(packageName: string, credentials: { client_email: string; private_key: string }) {
    this.packageName = packageName;
    this.clientEmail = credentials.client_email;
    this.privateKeyPem = credentials.private_key;
  }

  public static fromSettings(settings: GooglePlaySettings): GooglePlayClient {
    let credentials: { client_email: string; private_key: string };
    try {
      credentials = JSON.parse(settings.service_account_json);
    } catch (err) {
      throw new Error('GooglePlayClient: service_account_json is not valid JSON');
    }
    if (!credentials.client_email || !credentials.private_key) {
      throw new Error('GooglePlayClient: service account JSON missing client_email or private_key');
    }
    return new GooglePlayClient(settings.package_name, credentials);
  }

  // eslint-disable-next-line require-await -- `async` kept for API uniformity with the rest of GooglePlayClient
  public async getSubscription(subscriptionId: string, purchaseToken: string): Promise<GooglePlaySubscriptionPurchase> {
    const url = `${ANDROID_PUBLISHER_BASE}/applications/${encodeURIComponent(
      this.packageName
    )}/purchases/subscriptions/${encodeURIComponent(subscriptionId)}/tokens/${encodeURIComponent(purchaseToken)}`;
    return this.apiGet<GooglePlaySubscriptionPurchase>(url, 'verifySub');
  }

  // eslint-disable-next-line require-await -- `async` kept for API uniformity with the rest of GooglePlayClient
  public async getProductPurchase(productId: string, purchaseToken: string): Promise<GooglePlayProductPurchase> {
    const url = `${ANDROID_PUBLISHER_BASE}/applications/${encodeURIComponent(
      this.packageName
    )}/purchases/products/${encodeURIComponent(productId)}/tokens/${encodeURIComponent(purchaseToken)}`;
    return this.apiGet<GooglePlayProductPurchase>(url, 'verifyINAPP');
  }

  /**
   * Acknowledge a subscription purchase. Google requires this within 3 days of
   * purchase, otherwise the order auto-refunds. POST returns 204 on success;
   * 400 with "alreadyAcknowledged" if already acked (safe to swallow).
   */
  public async acknowledgeSubscription(subscriptionId: string, purchaseToken: string): Promise<void> {
    const url = `${ANDROID_PUBLISHER_BASE}/applications/${encodeURIComponent(
      this.packageName
    )}/purchases/subscriptions/${encodeURIComponent(subscriptionId)}/tokens/${encodeURIComponent(
      purchaseToken
    )}:acknowledge`;
    await this.apiAcknowledge(url, 'acknowledgeSubscription');
    logger.info('google_play subscription acknowledged', { subscriptionId, purchaseToken });
  }

  public async acknowledgeProduct(productId: string, purchaseToken: string): Promise<void> {
    const url = `${ANDROID_PUBLISHER_BASE}/applications/${encodeURIComponent(
      this.packageName
    )}/purchases/products/${encodeURIComponent(productId)}/tokens/${encodeURIComponent(purchaseToken)}:acknowledge`;
    await this.apiAcknowledge(url, 'acknowledgeProduct');
    logger.info('google_play product acknowledged', { productId, purchaseToken });
  }

  // -- internal: OAuth2 + HTTP -------------------------------------------------

  private async apiGet<T>(url: string, opName: string): Promise<T> {
    const token = await this.getAccessToken();
    const resp = await fetch(url, {
      method: 'GET',
      headers: { Authorization: `Bearer ${token}`, Accept: 'application/json' },
    });
    if (resp.status === 401) {
      // Token may have just expired between cache hit and request; refresh once.
      this.accessToken = null;
      const retryToken = await this.getAccessToken();
      const retry = await fetch(url, {
        method: 'GET',
        headers: { Authorization: `Bearer ${retryToken}`, Accept: 'application/json' },
      });
      return parseOrThrow<T>(retry, opName);
    }
    return parseOrThrow<T>(resp, opName);
  }

  private async apiAcknowledge(url: string, opName: string): Promise<void> {
    const token = await this.getAccessToken();
    const resp = await fetch(url, {
      method: 'POST',
      headers: {
        Authorization: `Bearer ${token}`,
        'Content-Type': 'application/json',
        Accept: 'application/json',
      },
      // Google accepts an empty JSON body; developerPayload is optional and
      // not used in our flow (we link purchases via obfuscatedExternalAccountId).
      body: '{}',
    });
    // 204 No Content on success. 400 with reason=alreadyAcknowledged is fine.
    if (resp.status === 204 || resp.ok) return;
    const body = await resp.text();
    if (resp.status === 400 && body.includes('alreadyAcknowledged')) return;
    throw new Error(`Google Play ${opName} failed: HTTP ${resp.status} ${body.slice(0, 300)}`);
  }

  private async getAccessToken(): Promise<string> {
    const now = Math.floor(Date.now() / 1000);
    if (this.accessToken && this.accessToken.expiresAt - ACCESS_TOKEN_REFRESH_LEEWAY_SEC > now) {
      return this.accessToken.token;
    }
    const fresh = await this.fetchAccessToken();
    this.accessToken = fresh;
    return fresh.token;
  }

  private async fetchAccessToken(): Promise<AccessTokenCache> {
    const now = Math.floor(Date.now() / 1000);
    const claim = {
      iss: this.clientEmail,
      scope: ANDROID_PUBLISHER_SCOPE,
      aud: OAUTH_TOKEN_URL,
      exp: now + 3600,
      iat: now,
    };
    const jwt = await this.signJwt(claim);
    const resp = await fetch(OAUTH_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: jwt,
      }).toString(),
    });
    const text = await resp.text();
    if (!resp.ok) {
      throw new Error(`Google OAuth token exchange failed: HTTP ${resp.status} ${text.slice(0, 300)}`);
    }
    const data = JSON.parse(text) as { access_token: string; expires_in: number };
    if (!data.access_token) {
      throw new Error(`Google OAuth token exchange missing access_token: ${text.slice(0, 300)}`);
    }
    return { token: data.access_token, expiresAt: now + (data.expires_in ?? 3600) };
  }

  private async signJwt(claim: Record<string, unknown>): Promise<string> {
    const header = { alg: 'RS256', typ: 'JWT' };
    const headerB64 = base64UrlEncode(new TextEncoder().encode(JSON.stringify(header)));
    const claimB64 = base64UrlEncode(new TextEncoder().encode(JSON.stringify(claim)));
    const signingInput = `${headerB64}.${claimB64}`;
    const key = await this.getSigningKey();
    const sig = await crypto.subtle.sign({ name: 'RSASSA-PKCS1-v1_5' }, key, new TextEncoder().encode(signingInput));
    return `${signingInput}.${base64UrlEncode(new Uint8Array(sig))}`;
  }

  private getSigningKey(): Promise<CryptoKey> {
    if (!this.signingKey) {
      this.signingKey = importPkcs8(this.privateKeyPem);
    }
    return this.signingKey;
  }
}

// -- module helpers ----------------------------------------------------------

async function parseOrThrow<T>(resp: Response, opName: string): Promise<T> {
  if (!resp.ok) {
    const body = await resp.text();
    throw new Error(`Google Play ${opName} failed: HTTP ${resp.status} ${body.slice(0, 500)}`);
  }
  return (await resp.json()) as T;
}

function importPkcs8(pem: string): Promise<CryptoKey> {
  // Service-account private_key is PKCS#8 PEM with literal \n sequences when
  // it came out of JSON-stringification.
  const normalized = pem.replace(/\\n/g, '\n');
  const body = normalized
    .replace(/-----BEGIN PRIVATE KEY-----/, '')
    .replace(/-----END PRIVATE KEY-----/, '')
    .replace(/\s+/g, '');
  const der = base64DecodeToBytes(body);
  return crypto.subtle.importKey('pkcs8', der, { name: 'RSASSA-PKCS1-v1_5', hash: 'SHA-256' }, false, ['sign']);
}

function base64DecodeToBytes(b64: string): Uint8Array {
  // atob exists in CF Workers and Node 18+.
  const bin = atob(b64);
  const out = new Uint8Array(bin.length);
  for (let i = 0; i < bin.length; i += 1) out[i] = bin.charCodeAt(i);
  return out;
}

function base64UrlEncode(bytes: Uint8Array): string {
  let bin = '';
  for (let i = 0; i < bytes.length; i += 1) bin += String.fromCharCode(bytes[i]!);
  return btoa(bin).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
}
