import { OAuth2Client } from '../client.ts';
import type { OAuth2Token } from '../token.ts';
import type { AuthorizationCodeRequest } from '../messages.ts';
import { OAuth2Error } from '../error.ts';

type GetAuthorizeUrlParams = {
  /**
   * Where to redirect the user back to after authentication.
   */
  redirectUri: string;

  /**
   * The 'state' is a string that can be sent to the authentication server,
   * and back to the redirectUri.
   */
  state?: string;

  /**
   * Code verifier for PKCE support. If you used this in the redirect
   * to the authorization endpoint, you also need to use this again
   * when getting the access_token on the token endpoint.
   */
  codeVerifier?: string;

  /**
   * List of scopes.
   */
  scope?: string[];

  /**
   * The resource the client intends to access.
   *
   * This is defined in RFC 8707.
   */
  resource?: string[] | string;

  /**
   * Any parameters listed here will be added to the query string for the authorization server endpoint.
   */
  extraParams?: Record<string, string>;

  /**
   * By default response parameters for the authorization_flow will be added
   * to the query string.
   *
   * Some servers let you put this in the fragment instead. This may be
   * benefical if your client is a browser, as embedding the authorization
   * code in the fragment part of the URI prevents it from being sent back
   * to the server.
   *
   * See: https://openid.net/specs/oauth-v2-multiple-response-types-1_0.html
   */
  responseMode?: 'query' | 'fragment';
}

type ValidateResponseResult = {

  /**
   * The authorization code. This code should be used to obtain an access token.
   */
  code: string;

  /**
   * List of scopes that the client requested.
   */
  scope?: string[];

}

type GetTokenParams = {

  code: string;

  redirectUri: string;
  state?: string;
  codeVerifier?:string;

  /**
   * The resource the client intends to access.
   *
   * @see https://datatracker.ietf.org/doc/html/rfc8707
   */
  resource?: string[] | string;

}

export class OAuth2AuthorizationCodeClient {

  client: OAuth2Client;

  constructor(client: OAuth2Client) {

    this.client = client;

  }

  /**
   * Returns the URi that the user should open in a browser to initiate the
   * authorization_code flow.
   */
  async getAuthorizeUri(params: GetAuthorizeUrlParams): Promise<string> {

    const [
      codeChallenge,
      authorizationEndpoint
    ] = await Promise.all([
      params.codeVerifier ? getCodeChallenge(params.codeVerifier) : undefined,
      this.client.getEndpoint('authorizationEndpoint')
    ]);

    const query = new URLSearchParams({
      client_id: this.client.settings.clientId,
      response_type: 'code',
      redirect_uri: params.redirectUri,
    });
    if (codeChallenge) {
      query.set('code_challenge_method', codeChallenge[0]);
      query.set('code_challenge', codeChallenge[1]);
    }
    if (params.state) {
      query.set('state', params.state);
    }
    if (params.scope) {
      query.set('scope', params.scope.join(' '));
    }

    if (params.resource) for(const resource of [].concat(params.resource as any)) {
      query.append('resource', resource);
    }

    if (params.responseMode && params.responseMode!=='query') {
      query.append('response_mode', params.responseMode);
    }

    if (params.extraParams) for(const [k,v] of Object.entries(params.extraParams)) {
      if (query.has(k)) throw new Error(`Property in extraParams would overwrite standard property: ${k}`);
      query.set(k, v);
    }

    return authorizationEndpoint + '?' + query.toString();

  }

  async getTokenFromCodeRedirect(url: string|URL, params: Omit<GetTokenParams, 'code'> ): Promise<OAuth2Token> {

    const { code } = this.validateResponse(url, {
      state: params.state
    });

    return this.getToken({
      code,
      redirectUri: params.redirectUri,
      codeVerifier: params.codeVerifier,
    });

  }

  /**
   * After the user redirected back from the authorization endpoint, the
   * url will contain a 'code' and other information.
   *
   * This function takes the url and validate the response. If the user
   * redirected back with an error, an error will be thrown.
   */
  validateResponse(url: string|URL, params: {state?: string}): ValidateResponseResult {

    url = new URL(url);
    let queryParams = url.searchParams;
    if (!queryParams.has('code') && !queryParams.has('error') && url.hash.length>0) {
      // Try the fragment
      queryParams = new URLSearchParams(url.hash.slice(1));
    }

    if (queryParams.has('error')) {
      throw new OAuth2Error(
        queryParams.get('error_description') ?? 'OAuth2 error',
        queryParams.get('error') as any,
      );
    }

    if (!queryParams.has('code')) throw new Error(`The url did not contain a code parameter ${url}`);

    if (params.state && params.state !== queryParams.get('state')) {
      throw new Error(`The "state" parameter in the url did not match the expected value of ${params.state}`);
    }

    return {
      code: queryParams.get('code')!,
      scope: queryParams.has('scope') ? queryParams.get('scope')!.split(' ') : undefined,
    };

  }


  /**
   * Receives an OAuth2 token using 'authorization_code' grant
   */
  async getToken(params: GetTokenParams): Promise<OAuth2Token> {

    const body:AuthorizationCodeRequest = {
      grant_type: 'authorization_code',
      code: params.code,
      redirect_uri: params.redirectUri,
      code_verifier: params.codeVerifier,
      resource: params.resource,
    };
    return this.client.tokenResponseToOAuth2Token(this.client.request('tokenEndpoint', body));

  }


}

export async function generateCodeVerifier(): Promise<string> {

  const webCrypto = await getWebCrypto();
  const arr = new Uint8Array(32);
  webCrypto.getRandomValues(arr);
  return base64Url(arr);

}

export async function getCodeChallenge(codeVerifier: string): Promise<['plain' | 'S256', string]> {

  const webCrypto = await getWebCrypto();
  return ['S256', base64Url(await webCrypto.subtle.digest('SHA-256', stringToBuffer(codeVerifier)))];
}

async function getWebCrypto(): Promise<typeof window.crypto> {

  // Browsers
  if ((typeof window !== 'undefined' && window.crypto)) {
    if (!window.crypto.subtle?.digest) {
      throw new Error(
        "The context/environment is not secure, and does not support the 'crypto.subtle' module. See: https://developer.mozilla.org/en-US/docs/Web/API/Crypto/subtle for details"
      );
    }
    return window.crypto;
  }
  // Web workers possibly
  if ((typeof self !== 'undefined' && self.crypto)) {
    return self.crypto;
  }
  // Node
  // eslint-disable-next-line @typescript-eslint/no-var-requires
  const crypto = await import('crypto');
  return crypto.webcrypto as typeof window.crypto;

}

function stringToBuffer(input: string): ArrayBuffer {

  const buf = new Uint8Array(input.length);
  for(let i=0; i<input.length;i++) {
    buf[i] = input.charCodeAt(i) & 0xFF;
  }
  return buf;

}

function base64Url(buf: ArrayBuffer) {
  return (
    btoa(String.fromCharCode(...new Uint8Array(buf)))
      .replace(/\+/g, '-')
      .replace(/\//g, '_')
      .replace(/=+$/, '')
  );
}

