import { Injectable, Logger } from '@nestjs/common';
import * as client from 'openid-client';
import { SsoConfigService } from './sso-config.service';
import { SsoError } from './sso-errors';

/**
 * Entra ID OIDC client 封装（openid-client v6）。
 *
 * 事实源：docs/modules/organization/03-architecture.md
 *   §「OIDC 实现库」 / 「校验清单」
 *
 * 关键决策：
 *  - 用 openid-client 而非 passport-azure-ad（微软已弃用） / 手写 jose
 *  - discovery 延迟加载（首次需要时拉一次 + 失败缓存 30s cooldown）
 *  - JWKS 5min 缓存由 openid-client 默认承担
 *  - issuer 比对：用 discovery metadata.issuer 模板 {tenantid} 替换后**严格字符串相等**
 *  - token endpoint 超时 5s，**不重试**（按 03-arch decision card）
 */
@Injectable()
export class SsoOidcClientService {
  private readonly logger = new Logger(SsoOidcClientService.name);
  private readonly TOKEN_ENDPOINT_TIMEOUT_MS = 5000;

  private configPromise: Promise<client.Configuration> | null = null;
  private lastDiscoveryError: { at: number; err: unknown } | null = null;
  private readonly DISCOVERY_FAIL_COOLDOWN_MS = 30_000;

  constructor(private readonly ssoConfig: SsoConfigService) {}

  /**
   * 延迟获取 OIDC client.Configuration（discovery）。
   *
   * 第一次调用 → 拉 Entra OIDC discovery document
   * 后续 → 复用同一 Configuration 对象（JWKS 缓存在内部）
   * 失败 → 30s cooldown，期间直接抛 SSO_PROVIDER_UNAVAILABLE
   */
  private async getConfig(): Promise<client.Configuration> {
    if (this.configPromise) return this.configPromise;

    if (this.lastDiscoveryError) {
      const elapsed = Date.now() - this.lastDiscoveryError.at;
      if (elapsed < this.DISCOVERY_FAIL_COOLDOWN_MS) {
        throw new SsoError(
          'SSO_PROVIDER_UNAVAILABLE',
          `OIDC discovery 处于失败冷却期（剩余 ${this.DISCOVERY_FAIL_COOLDOWN_MS - elapsed}ms）`,
        );
      }
      // cooldown 已过，允许重试
      this.lastDiscoveryError = null;
    }

    const tenantId = this.ssoConfig.tenantId;
    const clientId = this.ssoConfig.clientId;
    const clientSecret = this.ssoConfig.clientSecret;
    const serverUrl = new URL(`https://login.microsoftonline.com/${tenantId}/v2.0`);

    this.configPromise = client
      .discovery(serverUrl, clientId, undefined, client.ClientSecretPost(clientSecret))
      .then((cfg) => {
        this.logger.log(`OIDC discovery 成功：${serverUrl.href}`);
        return cfg;
      })
      .catch((err) => {
        this.configPromise = null;
        this.lastDiscoveryError = { at: Date.now(), err };
        this.logger.error(`OIDC discovery 失败：${err?.message ?? err}`);
        throw new SsoError(
          'SSO_PROVIDER_UNAVAILABLE',
          `OIDC discovery 失败：${err?.message ?? 'unknown'}`,
        );
      });

    return this.configPromise;
  }

  /**
   * 生成 Microsoft authorize URL（PKCE 必须 + code_challenge_method=S256）。
   *
   * 调用方负责生成 state / nonce / code_verifier，并把它们写入 cookie。
   */
  async buildAuthorizationUrl(params: {
    state: string;
    nonce: string;
    codeVerifier: string;
    redirectUri?: string;
  }): Promise<string> {
    const config = await this.getConfig();
    const codeChallenge = await client.calculatePKCECodeChallenge(params.codeVerifier);
    const url = client.buildAuthorizationUrl(config, {
      redirect_uri: params.redirectUri ?? this.ssoConfig.redirectUri,
      scope: 'openid profile email',
      response_type: 'code',
      state: params.state,
      nonce: params.nonce,
      code_challenge: codeChallenge,
      code_challenge_method: 'S256',
    });
    return url.href;
  }

  /**
   * 用 authorization code 换 ID token + access token，并校验 ID token。
   *
   * openid-client v6 的 authorizationCodeGrant 内部一次性完成：
   *  - 把 code + code_verifier 发到 token endpoint
   *  - 校验签名 / iss / aud / exp / nbf / iat
   *  - 校验 nonce（与传入的 expectedNonce 相等）
   *  - 校验 issuer 严格字符串相等（discovery metadata.issuer 自动模板替换）
   *  - clock skew ±5min（默认）
   *
   * 任一失败 → 抛 SSO_TOKEN_INVALID；5s 超时 / 5xx → SSO_PROVIDER_UNAVAILABLE
   */
  async exchangeCodeForTokens(params: {
    callbackUrl: URL;
    codeVerifier: string;
    expectedNonce: string;
    expectedState: string;
  }): Promise<{
    idTokenClaims: Record<string, any>;
    accessToken: string;
    rawIdToken: string;
  }> {
    const config = await this.getConfig();

    // openid-client v6 不支持 AbortSignal；用 Promise.race 实现 5s 超时
    let timeoutHandle: NodeJS.Timeout | undefined;
    const timeoutPromise = new Promise<never>((_, reject) => {
      timeoutHandle = setTimeout(() => {
        const e: any = new Error('token endpoint timeout');
        e.name = 'AbortError';
        reject(e);
      }, this.TOKEN_ENDPOINT_TIMEOUT_MS);
    });

    try {
      const tokens = await Promise.race([
        client.authorizationCodeGrant(
          config,
          params.callbackUrl,
          {
            pkceCodeVerifier: params.codeVerifier,
            expectedNonce: params.expectedNonce,
            expectedState: params.expectedState,
          },
        ),
        timeoutPromise,
      ]);

      const claims = tokens.claims();
      if (!claims) {
        throw new SsoError('SSO_TOKEN_INVALID', 'ID token claims 为空');
      }
      const rawIdToken = (tokens as any).id_token as string | undefined;
      const accessToken = (tokens as any).access_token as string | undefined;
      if (!rawIdToken || !accessToken) {
        throw new SsoError('SSO_TOKEN_INVALID', 'token 响应缺少 id_token 或 access_token');
      }

      return {
        idTokenClaims: claims as Record<string, any>,
        accessToken,
        rawIdToken,
      };
    } catch (err: any) {
      if (err instanceof SsoError) throw err;
      const msg = err?.message ?? String(err);
      const isAbort = err?.name === 'AbortError' || /aborted|timeout/i.test(msg);
      const httpStatus: number | undefined = err?.cause?.status ?? err?.status;
      if (isAbort) {
        this.logger.warn(`token endpoint 超时 (${this.TOKEN_ENDPOINT_TIMEOUT_MS}ms)`);
        throw new SsoError('SSO_PROVIDER_UNAVAILABLE', 'token endpoint timeout');
      }
      if (typeof httpStatus === 'number' && httpStatus >= 500) {
        throw new SsoError('SSO_PROVIDER_UNAVAILABLE', `token endpoint ${httpStatus}`);
      }
      // 其它（invalid_grant / 签名错 / iss / aud / nonce 不匹配 / exp 等）
      // openid-client v6 的 ResponseBodyError 把 Entra 的 error / error_description / error_codes / trace_id 藏在 err.cause
      const cause = err?.cause;
      const detail = {
        msg,
        status: httpStatus,
        causeName: cause?.name,
        error: cause?.error ?? err?.error,
        error_description: cause?.error_description ?? err?.error_description,
        error_codes: cause?.error_codes ?? err?.error_codes,
        trace_id: cause?.trace_id ?? err?.trace_id,
        correlation_id: cause?.correlation_id ?? err?.correlation_id,
        bodyText: typeof cause?.response?.text === 'function' ? '<stream>' : cause?.body ?? undefined,
      };
      this.logger.warn(`token 换取或 ID token 校验失败：${msg} | detail=${JSON.stringify(detail)}`);
      throw new SsoError('SSO_TOKEN_INVALID', msg);
    } finally {
      if (timeoutHandle) clearTimeout(timeoutHandle);
    }
  }
}
