/**
 * OAuth 2.0 JWT Bearer Flow (RFC 7523)
 * Service account authentication using private key signing
 */

import { SignJWT, importPKCS8 } from 'jose';
import * as fs from 'fs';
import { createPrivateKey } from 'crypto';
import { TokenStore } from '../domain/TokenStore.js';
import type { OAuthConfig } from '../types.js';

/**
 * Normalize any PEM-encoded private key to PKCS#8 (which is what jose's
 * importPKCS8 requires). GitHub Apps, `openssl genrsa`, and many other tools
 * emit PKCS#1 ("-----BEGIN RSA PRIVATE KEY-----"); we accept that, EC
 * ("-----BEGIN EC PRIVATE KEY-----"), and native PKCS#8 transparently.
 */
function normalizePrivateKeyPem(pem: string): string {
  const trimmed = pem.trim();
  if (trimmed.includes('-----BEGIN PRIVATE KEY-----')) return trimmed;
  const keyObject = createPrivateKey({ key: trimmed, format: 'pem' });
  const converted = keyObject.export({ format: 'pem', type: 'pkcs8' });
  return typeof converted === 'string' ? converted : converted.toString('utf8');
}

export class JWTBearerFlow {
  private tokenStore: TokenStore;
  private privateKey: string;

  constructor(private config: OAuthConfig) {
    const storageKey = config.storageKey || `jwt_bearer:${config.clientId}`;
    this.tokenStore = new TokenStore(storageKey, config.storage);

    // Load private key (raw). Deliberately NOT parsed here — parsing happens
    // lazily inside `generateJWT` so that construction succeeds for test
    // fixtures that pass placeholder strings and never actually sign a JWT.
    if (config.privateKey) {
      this.privateKey = config.privateKey;
    } else if (config.privateKeyPath) {
      try {
        this.privateKey = fs.readFileSync(config.privateKeyPath, 'utf8');
      } catch (error) {
        throw new Error(
          `Failed to read private key from ${config.privateKeyPath}: ${(error as Error).message}`
        );
      }
    } else {
      throw new Error('JWT Bearer flow requires privateKey or privateKeyPath');
    }
  }

  /**
   * Generate signed JWT assertion
   */
  private async generateJWT(): Promise<string> {
    // Back-date `iat` by 60s to tolerate clock skew with the token endpoint
    // (GitHub recommends this explicitly for GitHub Apps).
    const nowSec = Math.floor(Date.now() / 1000);
    const iat = nowSec - 60;
    const alg = this.config.tokenSigningAlg || 'RS256';
    // Default 1 hour; GitHub caps at 10 min, template overrides to 540.
    const lifetime = this.config.tokenLifetimeSeconds ?? 3600;

    // Parse private key. Normalize PKCS#1 / EC → PKCS#8 here (lazily), so
    // construction of the flow with placeholder fixtures in tests doesn't
    // trigger key parsing.
    let pkcs8Pem: string;
    try {
      pkcs8Pem = normalizePrivateKeyPem(this.privateKey);
    } catch (error) {
      throw new Error(
        `Invalid JWT Bearer private key: ${(error as Error).message}. ` +
          `Supply a PEM-encoded PKCS#1, PKCS#8, or EC private key.`
      );
    }
    const key = await importPKCS8(pkcs8Pem, alg);

    // Build JWT payload. For RFC 7523, `sub` is conventional; for GitHub App
    // auth it is not needed (only `iss` + `iat` + `exp`) but harmless.
    const builder = new SignJWT({})
      .setProtectedHeader({ alg })
      .setIssuer(this.config.clientId)
      .setIssuedAt(iat)
      .setExpirationTime(iat + lifetime);
    // GitHub App installation tokens reject JWTs with `aud`, `sub`, or `scope`.
    // Only include those for RFC 7523 flows.
    if ((this.config.tokenRequestStyle ?? 'form') !== 'bearer') {
      builder
        .setSubject(this.config.clientId)
        .setAudience(this.config.audience || this.config.tokenUrl);
      if (this.config.scope) {
        // replay scope via payload (jose doesn't expose a setter for arbitrary claims on SignJWT directly)
      }
    }
    const jwt = await builder.sign(key);

    return jwt;
  }

  /**
   * Get token using JWT Bearer assertion.
   *
   * `userId` / `accountId` are accepted for API compatibility with the
   * generic OAuthManager surface but deliberately ignored for token storage:
   * jwt_bearer is application-level auth (service account / GitHub App / RFC
   * 7523 grant) — there is exactly ONE token per app regardless of which
   * caller is requesting it. Partitioning the cache per user wastes storage
   * and produces invalid 3-part `flow:clientId:userId` storage keys that
   * strict host token-storage implementations (e.g. v25 MongoTokenStorage)
   * reject. If the underlying API needs per-user impersonation, that
   * happens at the API URL/header level (e.g. Microsoft Graph
   * `/users/{id}/...` routes), not at token retrieval.
   */
  async getToken(_userId?: string, _accountId?: string): Promise<string> {
    if (await this.tokenStore.isValid(this.config.refreshBeforeExpiry)) {
      return this.tokenStore.getAccessToken();
    }
    return this.requestToken();
  }

  /**
   * Request token using JWT assertion.
   *
   * Supports two delivery styles (controlled by `config.tokenRequestStyle`):
   * - `'form'` (default, RFC 7523) — form-urlencoded body with grant_type + assertion
   * - `'bearer'` (GitHub App installation tokens) — Authorization: Bearer <JWT> header
   *   on an empty POST; response uses `token` + `expires_at` instead of
   *   `access_token` + `expires_in`.
   */
  private async requestToken(): Promise<string> {
    const assertion = await this.generateJWT();
    const style = this.config.tokenRequestStyle ?? 'form';

    // Diagnostic: decode the JWT we're about to send (header+payload, not
    // signature) so auth failures surface with enough context to identify
    // iss / iat / exp mismatches at a glance.
    try {
      const [headerB64, payloadB64] = assertion.split('.');
      if (headerB64 && payloadB64) {
        const dec = (s: string) =>
          JSON.parse(Buffer.from(s.replace(/-/g, '+').replace(/_/g, '/'), 'base64').toString('utf8'));
        console.log(
          `[JWTBearer] POST ${this.config.tokenUrl} style=${style} header=${JSON.stringify(
            dec(headerB64)
          )} payload=${JSON.stringify(dec(payloadB64))}`
        );
      }
    } catch {
      /* ignore diagnostic decode failure */
    }

    let response: Response;
    if (style === 'bearer') {
      response = await fetch(this.config.tokenUrl, {
        method: 'POST',
        headers: {
          Accept: 'application/vnd.github+json',
          Authorization: `Bearer ${assertion}`,
          'X-GitHub-Api-Version': '2022-11-28',
        },
      });
    } else {
      const params = new URLSearchParams({
        grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer',
        assertion,
      });
      response = await fetch(this.config.tokenUrl, {
        method: 'POST',
        headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
        body: params,
      });
    }

    if (!response.ok) {
      const error = await response.text();
      throw new Error(
        `JWT Bearer token request failed: ${response.status} ${response.statusText} - ${error}`
      );
    }

    const data: any = await response.json();

    // Normalize GitHub-style responses (`token` + `expires_at`) to the standard
    // OAuth shape (`access_token` + `expires_in`) so downstream code and the
    // TokenStore see a consistent schema.
    if (style === 'bearer') {
      const token: string = data.token ?? data.access_token;
      const expiresIn: number =
        typeof data.expires_in === 'number'
          ? data.expires_in
          : data.expires_at
          ? Math.max(
              60,
              Math.floor((new Date(data.expires_at).getTime() - Date.now()) / 1000)
            )
          : 3600;
      const normalized = { ...data, access_token: token, expires_in: expiresIn };
      await this.tokenStore.storeToken(normalized);
      return token;
    }

    await this.tokenStore.storeToken(data);
    return data.access_token;
  }

  /**
   * Refresh token (generate new JWT and request new token).
   * userId/accountId ignored — see getToken.
   */
  async refreshToken(_userId?: string, _accountId?: string): Promise<string> {
    await this.tokenStore.clear();
    return this.requestToken();
  }

  /**
   * Check if token is valid. userId/accountId ignored — see getToken.
   */
  async isTokenValid(_userId?: string, _accountId?: string): Promise<boolean> {
    return this.tokenStore.isValid(this.config.refreshBeforeExpiry);
  }
}
